mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
22 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
362166651d | ||
|
|
a020117a78 | ||
|
|
9207280ab4 | ||
|
|
94384df36d | ||
|
|
730cc52187 | ||
|
|
e82db5b6db | ||
|
|
2f8c021819 | ||
|
|
72c2c02e49 | ||
|
|
48c0360111 | ||
|
|
63c947896e | ||
|
|
2a814b6bb6 | ||
|
|
3249881771 | ||
|
|
3b727ea708 | ||
|
|
a2a765f43d | ||
|
|
c17a815263 | ||
|
|
7a2dacaa61 | ||
|
|
359e00e69f | ||
|
|
bc9c56a224 | ||
|
|
6bef907416 | ||
|
|
6c7d655277 | ||
|
|
c132e64437 | ||
|
|
1dcb852ea1 |
1
.github/FUNDING.yml
vendored
1
.github/FUNDING.yml
vendored
@@ -1,3 +1,4 @@
|
|||||||
# These are supported funding model platforms
|
# These are supported funding model platforms
|
||||||
|
|
||||||
github: [orangecoding]
|
github: [orangecoding]
|
||||||
|
ko_fi: orangecoding
|
||||||
|
|||||||
47
.github/ISSUE_TEMPLATE/bug.yml
vendored
47
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -5,6 +5,40 @@ labels: [bug]
|
|||||||
assignees: []
|
assignees: []
|
||||||
|
|
||||||
body:
|
body:
|
||||||
|
- type: markdown
|
||||||
|
attributes:
|
||||||
|
value: |
|
||||||
|
## Please attach a debug bundle (available since Fredy 22.5.0+)
|
||||||
|
|
||||||
|
Since **Fredy 22.5.0** you can export a debug bundle that contains a system
|
||||||
|
snapshot (`sys.txt`, Fredy version, Node.js version, OS, Docker detection,
|
||||||
|
CPU, memory, sanitized settings) and the full log buffer (`logs.txt`) that
|
||||||
|
Fredy recorded while you reproduced the issue. Attaching it dramatically
|
||||||
|
speeds up triage.
|
||||||
|
Oh and before you ask: I decided against simply putting all logs into the debug
|
||||||
|
due to privacy reasons :)
|
||||||
|
|
||||||
|
**The bundle is only useful when the error is actually inside `logs.txt`.**
|
||||||
|
That means you have to record first, reproduce after:
|
||||||
|
|
||||||
|
1. Log in to Fredy as **admin** and open **Settings → Debug**.
|
||||||
|
2. Click **"Enable debug logging" / "Debug-Logging aktivieren"**. A red banner
|
||||||
|
appears across the whole app while recording is on.
|
||||||
|
3. **Now reproduce the bug.** Trigger the broken job, click the failing
|
||||||
|
button, wait for the failing scrape — whatever it was.
|
||||||
|
4. Come back to **Settings → Debug** and confirm the progress bar moved
|
||||||
|
(i.e. log entries were actually written). If it stayed at 0%, nothing was
|
||||||
|
captured and the bundle won't help us.
|
||||||
|
5. Click **"Download debug information" / "Debug Informationen herunterladen"**
|
||||||
|
and drop the resulting `FredyDebug-*.zip` into the "Screenshots / Logs"
|
||||||
|
field below.
|
||||||
|
6. Optional but recommended: click **"Disable debug logging"** to stop the
|
||||||
|
recording, and **"Delete stored debug logs"** once you have the zip so the
|
||||||
|
database does not keep them around.
|
||||||
|
|
||||||
|
On Fredy versions older than 22.5.0, paste the relevant log lines from your
|
||||||
|
console / Docker / systemd journal manually instead.
|
||||||
|
|
||||||
- type: textarea
|
- type: textarea
|
||||||
id: description
|
id: description
|
||||||
attributes:
|
attributes:
|
||||||
@@ -49,8 +83,11 @@ body:
|
|||||||
id: screenshots
|
id: screenshots
|
||||||
attributes:
|
attributes:
|
||||||
label: Screenshots / Logs
|
label: Screenshots / Logs
|
||||||
description: Add screenshots or paste log output to help explain the problem.
|
description: |
|
||||||
placeholder: "Drag and drop screenshots here, or paste logs."
|
Drop the FredyDebug-*.zip here (see the instructions at the top, available
|
||||||
|
since Fredy 22.5.0) and/or any additional screenshots. If you cannot produce
|
||||||
|
the bundle, paste relevant log lines instead.
|
||||||
|
placeholder: "Drag and drop the FredyDebug-*.zip and any screenshots here."
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
|
|
||||||
@@ -58,8 +95,10 @@ body:
|
|||||||
id: environment
|
id: environment
|
||||||
attributes:
|
attributes:
|
||||||
label: Environment
|
label: Environment
|
||||||
description: Provide details about your environment.
|
description: |
|
||||||
placeholder: "OS: macOS 15, Browser: Chrome 124, App version: 1.2.3"
|
Provide details about your environment. You can copy most of this from
|
||||||
|
sys.txt inside the debug bundle.
|
||||||
|
placeholder: "OS: macOS 15, Browser: Chrome 124, App version: 22.5.0, Docker: yes"
|
||||||
validations:
|
validations:
|
||||||
required: true
|
required: true
|
||||||
|
|
||||||
|
|||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ npm-debug.log
|
|||||||
.idea
|
.idea
|
||||||
.vscode
|
.vscode
|
||||||
tools/release/config.json
|
tools/release/config.json
|
||||||
|
.agents
|
||||||
@@ -46,7 +46,7 @@ index.js (startup)
|
|||||||
├── runMigrations()
|
├── runMigrations()
|
||||||
├── getProviders() # lazily imports lib/provider/*.js
|
├── getProviders() # lazily imports lib/provider/*.js
|
||||||
├── similarityCache.init() # preloads hash cache from DB
|
├── similarityCache.init() # preloads hash cache from DB
|
||||||
├── api.js # starts restana HTTP server
|
├── api.js # starts fastify HTTP server
|
||||||
└── initJobExecutionService() # registers event-bus listeners + starts scheduler
|
└── initJobExecutionService() # registers event-bus listeners + starts scheduler
|
||||||
|
|
||||||
scheduler (every N minutes) or manual trigger via POST /api/jobs/:id/run
|
scheduler (every N minutes) or manual trigger via POST /api/jobs/:id/run
|
||||||
|
|||||||
82
README.md
82
README.md
@@ -55,8 +55,11 @@ same listing twice.
|
|||||||
|
|
||||||
## 🤝 Sponsorship [](https://github.com/sponsors/orangecoding)
|
## 🤝 Sponsorship [](https://github.com/sponsors/orangecoding)
|
||||||
|
|
||||||
I maintain Fredy and other open-source projects in my free time.\
|
I maintain Fredy and other open-source projects in my free time, if you find it useful, consider supporting the project ❤️
|
||||||
If you find it useful, consider supporting the project 💙
|
|
||||||
|
#### Support me on
|
||||||
|
[Ko-Fi](https://ko-fi.com/orangecoding) | [Github](https://github.com/sponsors/orangecoding)
|
||||||
|
----
|
||||||
|
|
||||||
Fredy is proudly backed by the **JetBrains Open Source Support Program**.
|
Fredy is proudly backed by the **JetBrains Open Source Support Program**.
|
||||||
|
|
||||||
@@ -210,6 +213,50 @@ The data includes: names of active adapters/providers, OS, architecture, Node ve
|
|||||||
|
|
||||||
**Thanks**🤘
|
**Thanks**🤘
|
||||||
|
|
||||||
|
## 🐞 Debug Information
|
||||||
|
|
||||||
|
Since Fredy **22.5.0** there is a built-in way to capture everything Fredy logs into the
|
||||||
|
database for a limited time and download it as a single zip file. This is the recommended
|
||||||
|
way to attach diagnostics to a bug report. I decided against simply putting all logs into
|
||||||
|
a debug bundle due to privacy reasons!
|
||||||
|
|
||||||
|
**How it works**
|
||||||
|
|
||||||
|
- Debug logging is **opt-in** and admin-only. As long as it is off, Fredy behaves exactly
|
||||||
|
as before (console output only, nothing in the DB).
|
||||||
|
- When you turn it on, every log line (`debug`, `info`, `warn`, `error`) is additionally
|
||||||
|
written into the `debug_logs` SQLite table. The console keeps logging at its usual level.
|
||||||
|
- The recorded data is hard-capped at **5 MiB** via a rolling buffer: once the cap is hit,
|
||||||
|
the oldest entries are dropped automatically so the newest ones always survive.
|
||||||
|
- The on/off flag is persisted, so debug logging stays on across restarts (and you'll see
|
||||||
|
the warning banner everywhere until you turn it off again).
|
||||||
|
|
||||||
|
**Capturing a debug bundle**
|
||||||
|
|
||||||
|
1. Open Fredy as an **admin** and go to **Settings → Debug**.
|
||||||
|
2. Click **"Enable debug logging" / "Debug-Logging aktivieren"**. A red banner appears on
|
||||||
|
every page while recording is on.
|
||||||
|
3. **Reproduce the bug**.
|
||||||
|
4. Come back to **Settings → Debug** and check the progress bar, if it stayed at 0 %,
|
||||||
|
nothing was captured.
|
||||||
|
5. Click **"Download debug information" / "Debug Informationen herunterladen"**. You get a
|
||||||
|
zip named `YYYY-MM-DD-FredyDebug-<version>.zip` containing two files:
|
||||||
|
- `logs.txt` - every log line captured while recording was on, prefixed with timestamp
|
||||||
|
and level.
|
||||||
|
- `sys.txt` - runtime snapshot (Fredy version, Node.js version, OS, Docker detection,
|
||||||
|
CPU, memory, sanitized settings). Proxy credentials and session secrets are
|
||||||
|
**stripped** before export.
|
||||||
|
6. Attach the zip to the bug report.
|
||||||
|
7. Optional but recommended: click **"Disable debug logging"** to stop recording, and
|
||||||
|
**"Delete stored debug logs"** once you've sent the zip so the DB does not keep them
|
||||||
|
around.
|
||||||
|
|
||||||
|
**What is _not_ included**
|
||||||
|
|
||||||
|
- passwords/privacy relevant things
|
||||||
|
- Anything that Fredy itself does not pass through its `logger`. If a third-party library
|
||||||
|
writes directly to `process.stderr`, that output stays on the console only.
|
||||||
|
|
||||||
## 🛠️ Development
|
## 🛠️ Development
|
||||||
|
|
||||||
### Development Mode
|
### Development Mode
|
||||||
@@ -240,6 +287,37 @@ If you have to refresh the fixtures (every once in a while needed because the pr
|
|||||||
yarn run download-fixtures
|
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**.
|
||||||
|
|
||||||
------------------------------------------------------------------------
|
------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|||||||
7
index.js
7
index.js
@@ -10,6 +10,7 @@ import { runMigrations } from './lib/services/storage/migrations/migrate.js';
|
|||||||
import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js';
|
import { ensureDemoUserExists, ensureAdminUserExists } from './lib/services/storage/userStorage.js';
|
||||||
import { initTrackerCron } from './lib/services/crons/tracker-cron.js';
|
import { initTrackerCron } from './lib/services/crons/tracker-cron.js';
|
||||||
import logger from './lib/services/logger.js';
|
import logger from './lib/services/logger.js';
|
||||||
|
import { reloadEnabledFromSettings } from './lib/services/debug/debugLogStorage.js';
|
||||||
import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.js';
|
import { initActiveCheckerCron } from './lib/services/crons/listing-alive-cron.js';
|
||||||
import { initGeocodingCron } from './lib/services/crons/geocoding-cron.js';
|
import { initGeocodingCron } from './lib/services/crons/geocoding-cron.js';
|
||||||
import { getSettings } from './lib/services/storage/settingsStorage.js';
|
import { getSettings } from './lib/services/storage/settingsStorage.js';
|
||||||
@@ -42,6 +43,12 @@ await runMigrations();
|
|||||||
|
|
||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
|
|
||||||
|
// Restore the persisted on/off flag for opt-in DB log capture so it survives a
|
||||||
|
// Fredy restart. reloadEnabledFromSettings() also (un)wires the logger sink based
|
||||||
|
// on the restored flag, so the logger hot path stays cost-free when nobody enabled
|
||||||
|
// the feature.
|
||||||
|
await reloadEnabledFromSettings();
|
||||||
|
|
||||||
// Ensure the sqlite directory exists before loading anything else (based on config.sqlitepath)
|
// Ensure the sqlite directory exists before loading anything else (based on config.sqlitepath)
|
||||||
const { dir: sqliteDir } = await computeDbPath();
|
const { dir: sqliteDir } = await computeDbPath();
|
||||||
if (!fs.existsSync(sqliteDir)) {
|
if (!fs.existsSync(sqliteDir)) {
|
||||||
|
|||||||
@@ -38,11 +38,15 @@ import { formatListing } from './utils/formatListing.js';
|
|||||||
* 3) Normalize listings to the provider schema
|
* 3) Normalize listings to the provider schema
|
||||||
* 4) Filter out incomplete/blacklisted listings
|
* 4) Filter out incomplete/blacklisted listings
|
||||||
* 5) Identify new listings (vs. previously stored hashes)
|
* 5) Identify new listings (vs. previously stored hashes)
|
||||||
* 6) Persist new listings
|
* 6) Optionally enrich new listings via provider.fetchDetails
|
||||||
* 7) Filter out entries similar to already seen ones
|
* 7) Optionally re-apply the provider blacklist using the (now enriched)
|
||||||
* 8) Filter out entries that do not match the job's specFilter
|
* description — only when the user opted in via
|
||||||
* 9) Filter out entries that do not match the job's spatialFilter
|
* `blacklist_filter_on_provider_details`
|
||||||
* 10) Dispatch notifications
|
* 8) Persist new listings
|
||||||
|
* 9) Filter out entries similar to already seen ones
|
||||||
|
* 10) Filter out entries that do not match the job's specFilter
|
||||||
|
* 11) Filter out entries that do not match the job's spatialFilter
|
||||||
|
* 12) Dispatch notifications
|
||||||
*/
|
*/
|
||||||
class FredyPipelineExecutioner {
|
class FredyPipelineExecutioner {
|
||||||
/**
|
/**
|
||||||
@@ -86,6 +90,7 @@ class FredyPipelineExecutioner {
|
|||||||
.then(this._filter.bind(this))
|
.then(this._filter.bind(this))
|
||||||
.then(this._findNew.bind(this))
|
.then(this._findNew.bind(this))
|
||||||
.then(this._fetchDetails.bind(this))
|
.then(this._fetchDetails.bind(this))
|
||||||
|
.then(this._filterAfterDetails.bind(this))
|
||||||
.then(this._geocode.bind(this))
|
.then(this._geocode.bind(this))
|
||||||
.then(this._save.bind(this))
|
.then(this._save.bind(this))
|
||||||
.then(this._calculateDistance.bind(this))
|
.then(this._calculateDistance.bind(this))
|
||||||
@@ -259,13 +264,57 @@ class FredyPipelineExecutioner {
|
|||||||
listings
|
listings
|
||||||
// this should never filter some listings out, because the normalize function should always extract all fields.
|
// this should never filter some listings out, because the normalize function should always extract all fields.
|
||||||
.filter((item) => requiredKeys.every((key) => key in item))
|
.filter((item) => requiredKeys.every((key) => key in item))
|
||||||
|
// Drop listings missing a required identifying field *before* the provider
|
||||||
|
// filter runs, so provider filter functions never have to defend against a
|
||||||
|
// null id/link/title.
|
||||||
|
.filter((item) => requireValues.every((key) => item[key] != null))
|
||||||
// TODO: move blacklist filter to this file, so it will handle for all providers in same way.
|
// TODO: move blacklist filter to this file, so it will handle for all providers in same way.
|
||||||
.filter(this._providerConfig.filter)
|
.filter(this._providerConfig.filter)
|
||||||
// filter out listings that are missing required fields
|
|
||||||
.filter((item) => requireValues.every((key) => item[key] != null))
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Re-apply the provider's blacklist filter after `_fetchDetails` has had a
|
||||||
|
* chance to enrich the listings (e.g., load the full description from the
|
||||||
|
* detail page). The initial `_filter` step only sees the truncated snippet
|
||||||
|
* exposed on the search results page, so a blacklisted term that lives
|
||||||
|
* deeper in the listing's full description would otherwise slip through.
|
||||||
|
*
|
||||||
|
* Opt-in: gated by the user setting `blacklist_filter_on_provider_details`.
|
||||||
|
* The full detail description tends to contain a lot of boilerplate (legal,
|
||||||
|
* exposé contact info, generic marketing copy) which can accidentally match
|
||||||
|
* a blacklist term and remove otherwise relevant listings. Users who want
|
||||||
|
* the stricter behavior must enable the setting explicitly.
|
||||||
|
*
|
||||||
|
* Throws {@link NoNewListingsWarning} when all listings are filtered out
|
||||||
|
* so the rest of the pipeline (save + notify) is short-circuited.
|
||||||
|
*
|
||||||
|
* @param {ParsedListing[]} listings Enriched listings to re-filter.
|
||||||
|
* @returns {ParsedListing[]} Listings that still pass the provider's filter.
|
||||||
|
* @throws {NoNewListingsWarning} When every listing is filtered out.
|
||||||
|
*/
|
||||||
|
_filterAfterDetails(listings) {
|
||||||
|
if (typeof this._providerConfig.filter !== 'function') {
|
||||||
|
return listings;
|
||||||
|
}
|
||||||
|
const userId = getJob(this._jobKey)?.userId;
|
||||||
|
const enabled = getUserSettings(userId)?.blacklist_filter_on_provider_details === true;
|
||||||
|
if (!enabled) {
|
||||||
|
return listings;
|
||||||
|
}
|
||||||
|
const kept = listings.filter(this._providerConfig.filter);
|
||||||
|
const removed = listings.length - kept.length;
|
||||||
|
if (removed > 0) {
|
||||||
|
logger.debug(
|
||||||
|
`Re-filter after detail enrichment removed ${removed} listing(s) by blacklist (Provider: '${this._providerId}')`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (kept.length === 0) {
|
||||||
|
throw new NoNewListingsWarning();
|
||||||
|
}
|
||||||
|
return kept;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Determine which listings are new by comparing their IDs against stored hashes.
|
* Determine which listings are new by comparing their IDs against stored hashes.
|
||||||
*
|
*
|
||||||
@@ -275,9 +324,9 @@ class FredyPipelineExecutioner {
|
|||||||
*/
|
*/
|
||||||
_findNew(listings) {
|
_findNew(listings) {
|
||||||
logger.debug(`Checking ${listings.length} listings for new entries (Provider: '${this._providerId}')`);
|
logger.debug(`Checking ${listings.length} listings for new entries (Provider: '${this._providerId}')`);
|
||||||
const hashes = getKnownListingHashesForJobAndProvider(this._jobKey, this._providerId) || [];
|
const knownHashes = new Set(getKnownListingHashesForJobAndProvider(this._jobKey, this._providerId) || []);
|
||||||
|
|
||||||
const newListings = listings.filter((o) => !hashes.includes(o.id));
|
const newListings = listings.filter((o) => !knownHashes.has(o.id));
|
||||||
if (newListings.length === 0) {
|
if (newListings.length === 0) {
|
||||||
throw new NoNewListingsWarning();
|
throw new NoNewListingsWarning();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,4 +14,5 @@ export const TRACKING_POIS = {
|
|||||||
DETECTED_AS_BOT: 'DETECTED_AS_BOT',
|
DETECTED_AS_BOT: 'DETECTED_AS_BOT',
|
||||||
NOTES_CREATE: 'NOTES_CREATE',
|
NOTES_CREATE: 'NOTES_CREATE',
|
||||||
USING_LISTING_STATUS: 'USING_LISTING_STATUS',
|
USING_LISTING_STATUS: 'USING_LISTING_STATUS',
|
||||||
|
CHANGE_LANGUAGE: 'CHANGE_LANGUAGE',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import userSettingsPlugin from './routes/userSettingsRoute.js';
|
|||||||
import trackingPlugin from './routes/trackingRoute.js';
|
import trackingPlugin from './routes/trackingRoute.js';
|
||||||
import generalSettingsPlugin from './routes/generalSettingsRoute.js';
|
import generalSettingsPlugin from './routes/generalSettingsRoute.js';
|
||||||
import backupPlugin from './routes/backupRouter.js';
|
import backupPlugin from './routes/backupRouter.js';
|
||||||
|
import debugPlugin, { registerDebugPublicProbe } from './routes/debugRouter.js';
|
||||||
import userPlugin from './routes/userRoute.js';
|
import userPlugin from './routes/userRoute.js';
|
||||||
import notificationAdapterPlugin from './routes/notificationAdapterRouter.js';
|
import notificationAdapterPlugin from './routes/notificationAdapterRouter.js';
|
||||||
import providerPlugin from './routes/providerRouter.js';
|
import providerPlugin from './routes/providerRouter.js';
|
||||||
@@ -77,6 +78,16 @@ fastify.register(async (app) => {
|
|||||||
app.register(userSettingsPlugin, { prefix: '/api/user/settings' });
|
app.register(userSettingsPlugin, { prefix: '/api/user/settings' });
|
||||||
app.register(trackingPlugin, { prefix: '/api/tracking' });
|
app.register(trackingPlugin, { prefix: '/api/tracking' });
|
||||||
app.register(generalSettingsPlugin, { prefix: '/api/admin/generalSettings' });
|
app.register(generalSettingsPlugin, { prefix: '/api/admin/generalSettings' });
|
||||||
|
// The lightweight /api/debug/active probe used by the app-wide red banner. Lives
|
||||||
|
// here (under authHook, NOT adminHook) so non-admin users also see the warning
|
||||||
|
// banner when an admin has enabled the feature, without exposing the rest of the
|
||||||
|
// settings payload.
|
||||||
|
app.register(
|
||||||
|
async (sub) => {
|
||||||
|
registerDebugPublicProbe(sub);
|
||||||
|
},
|
||||||
|
{ prefix: '/api/debug' },
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Admin-only routes
|
// Admin-only routes
|
||||||
@@ -84,6 +95,7 @@ fastify.register(async (app) => {
|
|||||||
app.addHook('preHandler', authHook);
|
app.addHook('preHandler', authHook);
|
||||||
app.addHook('preHandler', adminHook);
|
app.addHook('preHandler', adminHook);
|
||||||
app.register(backupPlugin, { prefix: '/api/admin/backup' });
|
app.register(backupPlugin, { prefix: '/api/admin/backup' });
|
||||||
|
app.register(debugPlugin, { prefix: '/api/admin/debug' });
|
||||||
app.register(userPlugin, { prefix: '/api/admin/users' });
|
app.register(userPlugin, { prefix: '/api/admin/users' });
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,28 @@ function cap(val) {
|
|||||||
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
|
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the most recent job trigger timestamp across the given jobs.
|
||||||
|
*
|
||||||
|
* Returns `null` when none of the jobs has ever been triggered. The value is
|
||||||
|
* persisted per-job via `jobs.last_run_at`, so the dashboard reflects the
|
||||||
|
* scope visible to the current user (own + shared, or all for admins) rather
|
||||||
|
* than a process-wide in-memory value.
|
||||||
|
*
|
||||||
|
* @param {Array<{lastRunAt?: number|null}>} jobs
|
||||||
|
* @returns {number|null}
|
||||||
|
*/
|
||||||
|
function computeLastRun(jobs) {
|
||||||
|
let lastRun = null;
|
||||||
|
for (const job of jobs) {
|
||||||
|
const ts = job.lastRunAt;
|
||||||
|
if (typeof ts === 'number' && (lastRun == null || ts > lastRun)) {
|
||||||
|
lastRun = ts;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return lastRun;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {import('fastify').FastifyInstance} fastify
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
*/
|
*/
|
||||||
@@ -46,11 +68,13 @@ export default async function dashboardPlugin(fastify) {
|
|||||||
}
|
}
|
||||||
: { labels: [], values: [] };
|
: { labels: [], values: [] };
|
||||||
|
|
||||||
|
const lastRun = computeLastRun(jobs);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
general: {
|
general: {
|
||||||
interval: settings.interval,
|
interval: settings.interval,
|
||||||
lastRun: settings.lastRun || null,
|
lastRun,
|
||||||
nextRun: settings.lastRun == null ? 0 : settings.lastRun + settings.interval * 60000,
|
nextRun: lastRun == null ? 0 : lastRun + settings.interval * 60000,
|
||||||
},
|
},
|
||||||
kpis: {
|
kpis: {
|
||||||
totalJobs,
|
totalJobs,
|
||||||
|
|||||||
93
lib/api/routes/debugRouter.js
Normal file
93
lib/api/routes/debugRouter.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
isEnabled,
|
||||||
|
enableDebugLogging,
|
||||||
|
disableDebugLogging,
|
||||||
|
getCurrentSize,
|
||||||
|
getMaxSize,
|
||||||
|
hasAnyLogs,
|
||||||
|
wasEverEnabled,
|
||||||
|
clearAllDebugLogs,
|
||||||
|
} from '../../services/debug/debugLogStorage.js';
|
||||||
|
import { buildDebugBundleFileName, buildDebugBundleZip } from '../../services/debug/debugBundleService.js';
|
||||||
|
import { getSettings } from '../../services/storage/settingsStorage.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the JSON status payload returned by /status and after each enable/disable.
|
||||||
|
* @returns {Promise<{enabled:boolean, size:number, max:number, hasLogs:boolean, everEnabled:boolean}>}
|
||||||
|
*/
|
||||||
|
async function buildStatus() {
|
||||||
|
return {
|
||||||
|
enabled: isEnabled(),
|
||||||
|
size: await getCurrentSize(),
|
||||||
|
max: getMaxSize(),
|
||||||
|
hasLogs: hasAnyLogs(),
|
||||||
|
everEnabled: await wasEverEnabled(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register the lightweight /active probe used by the app-wide red banner. Exposed
|
||||||
|
* to every authenticated user (not just admins) so non-admin users see the warning
|
||||||
|
* banner too. Returns only a single boolean so it cannot be repurposed to leak any
|
||||||
|
* other state.
|
||||||
|
*
|
||||||
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
|
*/
|
||||||
|
export async function registerDebugPublicProbe(fastify) {
|
||||||
|
fastify.get('/active', async () => ({ enabled: isEnabled() }));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Admin-only debug logging endpoints.
|
||||||
|
*
|
||||||
|
* Routes (all relative to the registered prefix /api/admin/debug):
|
||||||
|
* GET /status → current feature status (used by the UI polling).
|
||||||
|
* POST /enable → turn debug logging on. Body: { clearPrevious?:boolean }.
|
||||||
|
* POST /disable → turn debug logging off (existing logs are kept on disk).
|
||||||
|
* GET /download → ZIP with logs.txt + sys.txt. 409 when the feature has
|
||||||
|
* never been enabled OR there are no logs to export.
|
||||||
|
* DELETE /logs → drop every stored debug log row (does NOT change the
|
||||||
|
* enabled flag — useful to free space while keeping
|
||||||
|
* recording on).
|
||||||
|
*
|
||||||
|
* @param {import('fastify').FastifyInstance} fastify
|
||||||
|
*/
|
||||||
|
export default async function debugPlugin(fastify) {
|
||||||
|
fastify.get('/status', async () => buildStatus());
|
||||||
|
|
||||||
|
fastify.post('/enable', async (request) => {
|
||||||
|
const clearPrevious = request.body?.clearPrevious === true;
|
||||||
|
await enableDebugLogging({ clearPrevious });
|
||||||
|
return buildStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.post('/disable', async () => {
|
||||||
|
await disableDebugLogging();
|
||||||
|
return buildStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.delete('/logs', async () => {
|
||||||
|
clearAllDebugLogs();
|
||||||
|
return buildStatus();
|
||||||
|
});
|
||||||
|
|
||||||
|
fastify.get('/download', async (request, reply) => {
|
||||||
|
const ever = await wasEverEnabled();
|
||||||
|
if (!ever || !hasAnyLogs()) {
|
||||||
|
return reply.code(409).send({
|
||||||
|
error: 'Debug logging has never produced any data on this Fredy installation.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
const settings = await getSettings();
|
||||||
|
const zipBuffer = await buildDebugBundleZip({ settings });
|
||||||
|
const fileName = await buildDebugBundleFileName();
|
||||||
|
reply.header('Content-Type', 'application/zip');
|
||||||
|
reply.header('Content-Disposition', `attachment; filename="${fileName}"`);
|
||||||
|
return reply.send(zipBuffer);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -29,7 +29,7 @@ export default async function jobPlugin(fastify) {
|
|||||||
fastify.get('/', async (request) => {
|
fastify.get('/', async (request) => {
|
||||||
const isUserAdmin = isAdmin(request);
|
const isUserAdmin = isAdmin(request);
|
||||||
return jobStorage
|
return jobStorage
|
||||||
.getJobs()
|
.getJobs({ includeDisabled: true })
|
||||||
.filter(
|
.filter(
|
||||||
(job) =>
|
(job) =>
|
||||||
isUserAdmin ||
|
isUserAdmin ||
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ export default async function listingsPlugin(fastify) {
|
|||||||
providerFilter,
|
providerFilter,
|
||||||
watchListFilter,
|
watchListFilter,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
|
hiddenOnly,
|
||||||
sortfield = null,
|
sortfield = null,
|
||||||
sortdir = 'asc',
|
sortdir = 'asc',
|
||||||
freeTextFilter,
|
freeTextFilter,
|
||||||
@@ -38,6 +39,7 @@ export default async function listingsPlugin(fastify) {
|
|||||||
};
|
};
|
||||||
const normalizedActivity = toBool(activityFilter);
|
const normalizedActivity = toBool(activityFilter);
|
||||||
const normalizedWatch = toBool(watchListFilter);
|
const normalizedWatch = toBool(watchListFilter);
|
||||||
|
const normalizedHidden = toBool(hiddenOnly) === true;
|
||||||
const allowedStatuses = ['applied', 'rejected', 'accepted', 'none'];
|
const allowedStatuses = ['applied', 'rejected', 'accepted', 'none'];
|
||||||
const normalizedStatus =
|
const normalizedStatus =
|
||||||
typeof statusFilter === 'string' && allowedStatuses.includes(statusFilter.toLowerCase())
|
typeof statusFilter === 'string' && allowedStatuses.includes(statusFilter.toLowerCase())
|
||||||
@@ -62,6 +64,7 @@ export default async function listingsPlugin(fastify) {
|
|||||||
providerFilter,
|
providerFilter,
|
||||||
watchListFilter: normalizedWatch,
|
watchListFilter: normalizedWatch,
|
||||||
statusFilter: normalizedStatus,
|
statusFilter: normalizedStatus,
|
||||||
|
hiddenOnly: normalizedHidden,
|
||||||
sortField: sortfield || null,
|
sortField: sortfield || null,
|
||||||
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
|
sortDir: sortdir === 'desc' ? 'desc' : 'asc',
|
||||||
userId: request.session.currentUser,
|
userId: request.session.currentUser,
|
||||||
@@ -192,4 +195,21 @@ export default async function listingsPlugin(fastify) {
|
|||||||
}
|
}
|
||||||
return reply.send();
|
return reply.send();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.post('/restore', async (request, reply) => {
|
||||||
|
const { ids } = request.body || {};
|
||||||
|
const settings = await getSettings();
|
||||||
|
try {
|
||||||
|
if (settings.demoMode && !isAdminFn(request)) {
|
||||||
|
return reply.code(403).send({ error: 'Sorry, but you cannot restore listings in demo mode ;)' });
|
||||||
|
}
|
||||||
|
if (Array.isArray(ids) && ids.length > 0) {
|
||||||
|
listingStorage.restoreListingsById(ids);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(error);
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
return reply.send();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,28 @@ export default async function userSettingsPlugin(fastify) {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.post('/blacklist-filter-on-details', async (request, reply) => {
|
||||||
|
const userId = request.session.currentUser;
|
||||||
|
const { blacklist_filter_on_provider_details } = 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 (typeof blacklist_filter_on_provider_details !== 'boolean') {
|
||||||
|
return reply.code(400).send({ error: 'blacklist_filter_on_provider_details must be a boolean.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
upsertSettings({ blacklist_filter_on_provider_details }, userId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating blacklist-filter-on-details setting', error);
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
fastify.post('/listings-view-mode', async (request, reply) => {
|
fastify.post('/listings-view-mode', async (request, reply) => {
|
||||||
const userId = request.session.currentUser;
|
const userId = request.session.currentUser;
|
||||||
const { listings_view_mode } = request.body;
|
const { listings_view_mode } = request.body;
|
||||||
@@ -168,4 +190,22 @@ export default async function userSettingsPlugin(fastify) {
|
|||||||
return reply.code(500).send({ error: error.message });
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ const mapListing = (listing, baseUrl) => ({
|
|||||||
size: listing.size,
|
size: listing.size,
|
||||||
title: listing.title,
|
title: listing.title,
|
||||||
url: listing.link,
|
url: listing.link,
|
||||||
fredyUrl: baseUrl && listing.id ? `${baseUrl}/listings/listing/${listing.id}` : null,
|
fredyUrl: baseUrl && listing.id ? `${baseUrl}/#/listings/listing/${listing.id}` : null,
|
||||||
});
|
});
|
||||||
|
|
||||||
export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
export const send = async ({ serviceName, newListings, notificationConfig, jobKey, baseUrl }) => {
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const mapListingsWithCid = async (serviceName, jobKey, listings, baseUrl) => {
|
|||||||
jobKey,
|
jobKey,
|
||||||
hasImage: false,
|
hasImage: false,
|
||||||
imageCid: '',
|
imageCid: '',
|
||||||
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
|
fredyUrl: baseUrl && l.id ? `${baseUrl}/#/listings/listing/${l.id}` : null,
|
||||||
};
|
};
|
||||||
|
|
||||||
if (imgUrl) {
|
if (imgUrl) {
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const mapListings = (serviceName, jobKey, listings, baseUrl) =>
|
|||||||
price: l.price || '',
|
price: l.price || '',
|
||||||
image,
|
image,
|
||||||
hasImage: Boolean(image),
|
hasImage: Boolean(image),
|
||||||
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
|
fredyUrl: baseUrl && l.id ? `${baseUrl}/#/listings/listing/${l.id}` : null,
|
||||||
serviceName,
|
serviceName,
|
||||||
jobKey,
|
jobKey,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ const mapListings = (serviceName, jobKey, listings, baseUrl) =>
|
|||||||
hasImage: Boolean(image),
|
hasImage: Boolean(image),
|
||||||
// optional plain text snippet
|
// optional plain text snippet
|
||||||
snippet: [l.address, l.price, l.size].filter(Boolean).join(' | '),
|
snippet: [l.address, l.price, l.size].filter(Boolean).join(' | '),
|
||||||
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
|
fredyUrl: baseUrl && l.id ? `${baseUrl}/#/listings/listing/${l.id}` : null,
|
||||||
serviceName,
|
serviceName,
|
||||||
jobKey,
|
jobKey,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ const mapListings = (serviceName, jobKey, listings, baseUrl) =>
|
|||||||
price: l.price || '',
|
price: l.price || '',
|
||||||
image,
|
image,
|
||||||
hasImage: Boolean(image),
|
hasImage: Boolean(image),
|
||||||
fredyUrl: baseUrl && l.id ? `${baseUrl}/listings/listing/${l.id}` : null,
|
fredyUrl: baseUrl && l.id ? `${baseUrl}/#/listings/listing/${l.id}` : null,
|
||||||
serviceName,
|
serviceName,
|
||||||
jobKey,
|
jobKey,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ function normalize(o) {
|
|||||||
const link = `${baseUrl}/expose/${o.id}.html`;
|
const link = `${baseUrl}/expose/${o.id}.html`;
|
||||||
const price = normalizePrice(o.price);
|
const price = normalizePrice(o.price);
|
||||||
const id = buildHash(o.id, price);
|
const id = buildHash(o.id, price);
|
||||||
const image = baseUrl + o.image;
|
const image = o.image == null ? null : baseUrl + o.image;
|
||||||
const address = o.address == null ? null : o.address.trim().replaceAll('/', ',');
|
const address = o.address == null ? null : o.address.trim().replaceAll('/', ',');
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -198,7 +198,9 @@ function normalize(o) {
|
|||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
return !isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
/** @type {ProviderConfig} */
|
/** @type {ProviderConfig} */
|
||||||
const config = {
|
const config = {
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ function normalize(o) {
|
|||||||
const originalId = o.id.split('/').pop();
|
const originalId = o.id.split('/').pop();
|
||||||
const id = buildHash(originalId, o.price);
|
const id = buildHash(originalId, o.price);
|
||||||
const link = o.link != null ? `https://www.mcmakler.de${o.link}` : o.link;
|
const link = o.link != null ? `https://www.mcmakler.de${o.link}` : o.link;
|
||||||
const [rooms, size] = o.tags.split(' | ');
|
const [rooms, size] = (o.tags || '').split(' | ');
|
||||||
const address = o.address?.replace(' / ', ' ') || null;
|
const address = o.address?.replace(' / ', ' ') || null;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
|
|||||||
@@ -42,7 +42,9 @@ function normalize(o) {
|
|||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
return !isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !isOneOf(o.title, appliedBlackList);
|
||||||
|
const descNotBlacklisted = !isOneOf(o.description, appliedBlackList);
|
||||||
|
return titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {ProviderConfig} */
|
/** @type {ProviderConfig} */
|
||||||
|
|||||||
@@ -21,7 +21,8 @@ function normalize(o) {
|
|||||||
const link = o.link != null ? decodeURIComponent(o.link) : config.url;
|
const link = o.link != null ? decodeURIComponent(o.link) : config.url;
|
||||||
|
|
||||||
const urlReg = new RegExp(/url\((.*?)\)/gim);
|
const urlReg = new RegExp(/url\((.*?)\)/gim);
|
||||||
const image = o.image != null ? urlReg.exec(o.image)[1] : null;
|
const imageMatch = o.image != null ? urlReg.exec(o.image) : null;
|
||||||
|
const image = imageMatch != null ? imageMatch[1] : null;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
link,
|
link,
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ function normalize(o) {
|
|||||||
const link = `https://www.wg-gesucht.de${o.link}`;
|
const link = `https://www.wg-gesucht.de${o.link}`;
|
||||||
const image = o.image != null ? o.image.replace('small', 'large') : null;
|
const image = o.image != null ? o.image.replace('small', 'large') : null;
|
||||||
const [rooms, city, road] = o.details?.split(' | ') || [];
|
const [rooms, city, road] = o.details?.split(' | ') || [];
|
||||||
|
const address = [city, road].filter(Boolean).join(', ') || null;
|
||||||
return {
|
return {
|
||||||
id,
|
id,
|
||||||
link,
|
link,
|
||||||
@@ -51,7 +52,7 @@ function normalize(o) {
|
|||||||
price: extractNumber(o.price),
|
price: extractNumber(o.price),
|
||||||
size: extractNumber(o.size),
|
size: extractNumber(o.size),
|
||||||
rooms: extractNumber(rooms),
|
rooms: extractNumber(rooms),
|
||||||
address: `${city}, ${road}`,
|
address,
|
||||||
image,
|
image,
|
||||||
description: o.description,
|
description: o.description,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ function normalize(o) {
|
|||||||
const [city = '', part = ''] = (o.description || '').split('-').map((v) => v.trim());
|
const [city = '', part = ''] = (o.description || '').split('-').map((v) => v.trim());
|
||||||
const address = `${part}, ${city}`;
|
const address = `${part}, ${city}`;
|
||||||
return {
|
return {
|
||||||
id: o.link.split('/').pop(),
|
id: o.link != null ? o.link.split('/').pop() : null,
|
||||||
link: o.link,
|
link: o.link,
|
||||||
title: o.title || '',
|
title: o.title || '',
|
||||||
price: extractNumber(o.price),
|
price: extractNumber(o.price),
|
||||||
@@ -38,7 +38,7 @@ function normalize(o) {
|
|||||||
function applyBlacklist(o) {
|
function applyBlacklist(o) {
|
||||||
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
const titleNotBlacklisted = !utils.isOneOf(o.title, appliedBlackList);
|
||||||
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
const descNotBlacklisted = !utils.isOneOf(o.description, appliedBlackList);
|
||||||
return o.id != null && o.title != null && titleNotBlacklisted && descNotBlacklisted && o.link.startsWith(o.link);
|
return o.id != null && o.title != null && o.link != null && titleNotBlacklisted && descNotBlacklisted;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** @type {ProviderConfig} */
|
/** @type {ProviderConfig} */
|
||||||
|
|||||||
263
lib/services/debug/debugBundleService.js
Normal file
263
lib/services/debug/debugBundleService.js
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import fs from 'fs';
|
||||||
|
import os from 'os';
|
||||||
|
import { getAllDebugLogs } from './debugLogStorage.js';
|
||||||
|
import { getPackageVersion } from '../../utils.js';
|
||||||
|
|
||||||
|
const LOGS_FILE_NAME = 'logs.txt';
|
||||||
|
const SYSTEM_INFO_FILE_NAME = 'sys.txt';
|
||||||
|
const DEBUG_FILE_PREFIX = 'FredyDebug-';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily resolve AdmZip via dynamic import so tests can swap it via globalThis.
|
||||||
|
* Mirrors the pattern used by backupRestoreService.js for consistency.
|
||||||
|
* @returns {Promise<any>}
|
||||||
|
*/
|
||||||
|
let _AdmZipSingleton = null;
|
||||||
|
async function getAdmZip() {
|
||||||
|
if (_AdmZipSingleton) return _AdmZipSingleton;
|
||||||
|
if (globalThis && globalThis.__TEST_ADM_ZIP__) {
|
||||||
|
_AdmZipSingleton = globalThis.__TEST_ADM_ZIP__;
|
||||||
|
return _AdmZipSingleton;
|
||||||
|
}
|
||||||
|
const mod = await import('adm-zip');
|
||||||
|
_AdmZipSingleton = (mod && mod.default) || mod;
|
||||||
|
return _AdmZipSingleton;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a Date as YYYY-MM-DD using local time. Used for the download filename.
|
||||||
|
* @param {Date} date
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatDateOnly(date) {
|
||||||
|
const yyyy = date.getFullYear();
|
||||||
|
const mm = String(date.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(date.getDate()).padStart(2, '0');
|
||||||
|
return `${yyyy}-${mm}-${dd}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the debug bundle filename, e.g. "2026-06-08-FredyDebug-22.5.0.zip".
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
export async function buildDebugBundleFileName() {
|
||||||
|
const version = await getPackageVersion();
|
||||||
|
return `${formatDateOnly(new Date())}-${DEBUG_FILE_PREFIX}${version}.zip`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a stored debug_logs row into a single text line. The format mirrors the
|
||||||
|
* console layout from logger.js so support staff sees familiar output:
|
||||||
|
* [YYYY-MM-DD HH:MM:SS] LEVEL: message
|
||||||
|
*
|
||||||
|
* @param {{ts:number, level:string, message:string}} row
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatLogLine(row) {
|
||||||
|
const d = new Date(row.ts);
|
||||||
|
const yyyy = d.getFullYear();
|
||||||
|
const mm = String(d.getMonth() + 1).padStart(2, '0');
|
||||||
|
const dd = String(d.getDate()).padStart(2, '0');
|
||||||
|
const hh = String(d.getHours()).padStart(2, '0');
|
||||||
|
const mi = String(d.getMinutes()).padStart(2, '0');
|
||||||
|
const ss = String(d.getSeconds()).padStart(2, '0');
|
||||||
|
const level = String(row.level || 'info').toUpperCase();
|
||||||
|
return `[${yyyy}-${mm}-${dd} ${hh}:${mi}:${ss}] ${level}: ${row.message}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Render every stored debug log row into a single newline-delimited text blob.
|
||||||
|
* Returns an empty string when there are no rows.
|
||||||
|
*
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
export function renderLogsTxt() {
|
||||||
|
const rows = getAllDebugLogs();
|
||||||
|
if (!rows || rows.length === 0) return '';
|
||||||
|
return rows.map(formatLogLine).join('\n') + '\n';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort Docker detection. Used as a context hint in sys.txt so issue triage
|
||||||
|
* knows whether the user runs the official container image.
|
||||||
|
*
|
||||||
|
* @returns {{inDocker:boolean, evidence:string[]}}
|
||||||
|
*/
|
||||||
|
function detectDocker() {
|
||||||
|
const evidence = [];
|
||||||
|
let inDocker = false;
|
||||||
|
|
||||||
|
if (process.env.FREDY_IN_DOCKER === 'true' || process.env.FREDY_IN_DOCKER === '1') {
|
||||||
|
inDocker = true;
|
||||||
|
evidence.push('FREDY_IN_DOCKER env var is set');
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (fs.existsSync('/.dockerenv')) {
|
||||||
|
inDocker = true;
|
||||||
|
evidence.push('/.dockerenv exists');
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
if (fs.existsSync('/proc/1/cgroup')) {
|
||||||
|
const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf-8');
|
||||||
|
if (/docker|containerd|kubepods/i.test(cgroup)) {
|
||||||
|
inDocker = true;
|
||||||
|
evidence.push('/proc/1/cgroup mentions a container runtime');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
return { inDocker, evidence };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip credentials from URL-like strings so they can safely appear in sys.txt.
|
||||||
|
* Returns the input unchanged for non-URL values.
|
||||||
|
* @param {string} value
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function sanitizeUrlLike(value) {
|
||||||
|
if (typeof value !== 'string' || value.length === 0) return value;
|
||||||
|
try {
|
||||||
|
const u = new URL(value);
|
||||||
|
if (u.username || u.password) {
|
||||||
|
u.username = '***';
|
||||||
|
u.password = '***';
|
||||||
|
}
|
||||||
|
return u.toString();
|
||||||
|
} catch {
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (!Number.isFinite(bytes)) return String(bytes);
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(2)} KiB`;
|
||||||
|
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
|
||||||
|
return `${(bytes / (1024 * 1024 * 1024)).toFixed(2)} GiB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDuration(seconds) {
|
||||||
|
if (!Number.isFinite(seconds)) return String(seconds);
|
||||||
|
const s = Math.floor(seconds);
|
||||||
|
const days = Math.floor(s / 86400);
|
||||||
|
const hours = Math.floor((s % 86400) / 3600);
|
||||||
|
const minutes = Math.floor((s % 3600) / 60);
|
||||||
|
const secs = s % 60;
|
||||||
|
return `${days}d ${hours}h ${minutes}m ${secs}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a plaintext system / runtime report for inclusion in the debug zip. Settings
|
||||||
|
* are sanitized, proxy URL credentials and session secrets are stripped before
|
||||||
|
* serialization.
|
||||||
|
*
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {object} [options.settings]
|
||||||
|
* @returns {Promise<string>}
|
||||||
|
*/
|
||||||
|
export async function buildSystemInfo({ settings = null } = {}) {
|
||||||
|
const fredyVersion = await getPackageVersion();
|
||||||
|
const docker = detectDocker();
|
||||||
|
const cpus = os.cpus() || [];
|
||||||
|
const procMem = process.memoryUsage();
|
||||||
|
|
||||||
|
const lines = [];
|
||||||
|
lines.push('# Fredy Debug Report');
|
||||||
|
lines.push(`Generated at: ${new Date().toISOString()}`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
lines.push('## Application');
|
||||||
|
lines.push(`Fredy version: ${fredyVersion}`);
|
||||||
|
lines.push(`Node.js version: ${process.version}`);
|
||||||
|
lines.push(`Process uptime: ${formatDuration(process.uptime())}`);
|
||||||
|
lines.push(`PID: ${process.pid}`);
|
||||||
|
lines.push(`Env (NODE_ENV): ${process.env.NODE_ENV || 'development'}`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
lines.push('## Operating System');
|
||||||
|
lines.push(`Platform: ${process.platform}`);
|
||||||
|
lines.push(`Architecture: ${process.arch}`);
|
||||||
|
lines.push(`OS type: ${os.type()}`);
|
||||||
|
lines.push(`OS release: ${os.release()}`);
|
||||||
|
lines.push(`OS version: ${typeof os.version === 'function' ? os.version() : 'n/a'}`);
|
||||||
|
lines.push(`Hostname: ${os.hostname()}`);
|
||||||
|
lines.push(`System uptime: ${formatDuration(os.uptime())}`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
lines.push('## Container');
|
||||||
|
lines.push(`Running in Docker: ${docker.inDocker ? 'yes' : 'no'}`);
|
||||||
|
if (docker.evidence.length > 0) {
|
||||||
|
lines.push(`Evidence: ${docker.evidence.join('; ')}`);
|
||||||
|
}
|
||||||
|
if (process.env.FREDY_IMAGE_TAG) {
|
||||||
|
lines.push(`Image tag: ${process.env.FREDY_IMAGE_TAG}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
lines.push('## Hardware');
|
||||||
|
lines.push(`CPU count: ${cpus.length}`);
|
||||||
|
lines.push(`CPU model: ${cpus[0]?.model || 'unknown'}`);
|
||||||
|
lines.push(`Total memory: ${formatBytes(os.totalmem())}`);
|
||||||
|
lines.push(`Free memory: ${formatBytes(os.freemem())}`);
|
||||||
|
lines.push(`Process RSS: ${formatBytes(procMem.rss)}`);
|
||||||
|
lines.push(`Process heapUsed: ${formatBytes(procMem.heapUsed)}`);
|
||||||
|
lines.push(`Process heapTotal: ${formatBytes(procMem.heapTotal)}`);
|
||||||
|
lines.push('');
|
||||||
|
|
||||||
|
if (settings && typeof settings === 'object') {
|
||||||
|
lines.push('## Settings (sanitized)');
|
||||||
|
const safe = { ...settings };
|
||||||
|
if (safe.proxyUrl) safe.proxyUrl = sanitizeUrlLike(safe.proxyUrl);
|
||||||
|
delete safe.session_secret;
|
||||||
|
delete safe.sessionSecret;
|
||||||
|
for (const [key, value] of Object.entries(safe)) {
|
||||||
|
let printed;
|
||||||
|
if (value == null) {
|
||||||
|
printed = 'null';
|
||||||
|
} else if (typeof value === 'object') {
|
||||||
|
try {
|
||||||
|
printed = JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
printed = String(value);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
printed = String(value);
|
||||||
|
}
|
||||||
|
lines.push(`${key}: ${printed}`);
|
||||||
|
}
|
||||||
|
lines.push('');
|
||||||
|
}
|
||||||
|
|
||||||
|
return lines.join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build the final debug bundle zip buffer (logs.txt + sys.txt). The caller is
|
||||||
|
* responsible for checking wasEverEnabled() before invoking this, we still produce
|
||||||
|
* a valid zip even when there are zero log rows (logs.txt will contain a placeholder)
|
||||||
|
* because the route layer handles the user-friendly 409 case.
|
||||||
|
*
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {object} [options.settings] Runtime settings to embed in sys.txt.
|
||||||
|
* @returns {Promise<Buffer>}
|
||||||
|
*/
|
||||||
|
export async function buildDebugBundleZip({ settings = null } = {}) {
|
||||||
|
const logsContent = renderLogsTxt() || 'No debug log entries are currently stored.\n';
|
||||||
|
const sysContent = await buildSystemInfo({ settings });
|
||||||
|
|
||||||
|
const AdmZip = await getAdmZip();
|
||||||
|
const zip = new AdmZip();
|
||||||
|
zip.addFile(LOGS_FILE_NAME, Buffer.from(logsContent, 'utf-8'));
|
||||||
|
zip.addFile(SYSTEM_INFO_FILE_NAME, Buffer.from(sysContent, 'utf-8'));
|
||||||
|
return zip.toBuffer();
|
||||||
|
}
|
||||||
346
lib/services/debug/debugLogStorage.js
Normal file
346
lib/services/debug/debugLogStorage.js
Normal file
@@ -0,0 +1,346 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import SqliteConnection from '../storage/SqliteConnection.js';
|
||||||
|
import { upsertSettings, getSettings } from '../storage/settingsStorage.js';
|
||||||
|
import logger from '../logger.js';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hard cap on the total UTF-8 byte length of stored log MESSAGES (5 MiB).
|
||||||
|
*
|
||||||
|
* Note: this measures the payload bytes (message strings only); SQLite per-row
|
||||||
|
* overhead (id, ts, level, byte_size columns + page housekeeping) means the actual
|
||||||
|
* sqlite_master page count for debug_logs can be larger than this cap by a constant
|
||||||
|
* factor. The cap is intentionally about user-visible payload to keep the math
|
||||||
|
* predictable and to align with what ends up in logs.txt.
|
||||||
|
*
|
||||||
|
* The cap is enforced via a rolling buffer: when the live size exceeds it, the
|
||||||
|
* oldest rows are removed until the size falls below the limit again.
|
||||||
|
* @type {number}
|
||||||
|
*/
|
||||||
|
export const MAX_DEBUG_LOG_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
|
/** Settings key persisting the active on/off flag. */
|
||||||
|
const SETTING_ENABLED = 'debug_logging_enabled';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings key persisting "this feature has been turned on at least once". Used to
|
||||||
|
* decide whether the download endpoint returns 409 (never enabled) or whether the
|
||||||
|
* "delete previous logs?" confirm dialog should be shown on (re)enable.
|
||||||
|
*/
|
||||||
|
const SETTING_EVER_ENABLED = 'debug_logging_ever_enabled';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached live byte size of all rows in debug_logs. Initialized lazily from the DB on
|
||||||
|
* the first call and kept in sync by append / clear / trim. Storing this in-memory
|
||||||
|
* avoids running SUM() on every single insert (logger writes can be very frequent).
|
||||||
|
* @type {number|null}
|
||||||
|
*/
|
||||||
|
let cachedSize = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached value of debug_logging_enabled. Reflects DB state; flipped by enable() /
|
||||||
|
* disable() so the logger hot-path does not have to hit the settings cache for every
|
||||||
|
* log line.
|
||||||
|
* @type {boolean|null}
|
||||||
|
*/
|
||||||
|
let cachedEnabled = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the UTF-8 byte length of a string. Falls back to character count for
|
||||||
|
* environments where Buffer is not available (vitest covers Node, so it always is).
|
||||||
|
* @param {string} str
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function byteLengthOf(str) {
|
||||||
|
if (typeof str !== 'string') return 0;
|
||||||
|
if (typeof Buffer !== 'undefined' && typeof Buffer.byteLength === 'function') {
|
||||||
|
return Buffer.byteLength(str, 'utf-8');
|
||||||
|
}
|
||||||
|
return str.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read the current total byte size from the DB and update the local cache.
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function refreshSizeFromDb() {
|
||||||
|
const rows = SqliteConnection.query('SELECT COALESCE(SUM(byte_size), 0) AS total FROM debug_logs');
|
||||||
|
cachedSize = Number(rows?.[0]?.total ?? 0);
|
||||||
|
return cachedSize;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lazily ensure the cached enabled/size values are up to date. Called by every public
|
||||||
|
* method that needs to know either value, so external init is not required.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
async function ensureCachesInitialized() {
|
||||||
|
if (cachedEnabled == null) {
|
||||||
|
const settings = await getSettings();
|
||||||
|
cachedEnabled = settings[SETTING_ENABLED] === true;
|
||||||
|
}
|
||||||
|
if (cachedSize == null) {
|
||||||
|
refreshSizeFromDb();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cached prepared statements used by trimToFit(). Initialized on first use so we do
|
||||||
|
* not pay the prepare cost on every overflow event, and skipped entirely when the
|
||||||
|
* feature is never activated.
|
||||||
|
* @type {{select:any, del:any}|null}
|
||||||
|
*/
|
||||||
|
let trimStatements = null;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop the oldest rows from debug_logs until the cached size falls below
|
||||||
|
* MAX_DEBUG_LOG_BYTES. Implements the rolling buffer behavior chosen for the feature.
|
||||||
|
*
|
||||||
|
* The deletion is performed in batches of up to 100 oldest rows wrapped in a single
|
||||||
|
* transaction. The size cache is updated only after the transaction commits, so a
|
||||||
|
* mid-batch failure (rolled back by SQLite) cannot leave cachedSize out of sync with
|
||||||
|
* the on-disk reality. A defensive resync via SUM() is performed on transaction
|
||||||
|
* failure to recover from any unexpected drift.
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function trimToFit() {
|
||||||
|
if (cachedSize == null || cachedSize <= MAX_DEBUG_LOG_BYTES) return;
|
||||||
|
|
||||||
|
const db = SqliteConnection.getConnection();
|
||||||
|
if (trimStatements == null) {
|
||||||
|
trimStatements = {
|
||||||
|
select: db.prepare('SELECT id, byte_size FROM debug_logs ORDER BY id ASC LIMIT 100'),
|
||||||
|
del: db.prepare('DELETE FROM debug_logs WHERE id = @id'),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
while (cachedSize > MAX_DEBUG_LOG_BYTES) {
|
||||||
|
const oldest = trimStatements.select.all();
|
||||||
|
if (oldest.length === 0) {
|
||||||
|
// Table is empty but the cache still claims we are over the cap. That can only
|
||||||
|
// happen if cachedSize drifted (e.g. external DB modification, zero-byte
|
||||||
|
// messages that never contributed to SUM(byte_size), or a previous trim that
|
||||||
|
// partially succeeded). Resync from the source of truth and bail out.
|
||||||
|
refreshSizeFromDb();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pick exactly enough oldest rows to bring the cache back under the cap. We do
|
||||||
|
// NOT delete the entire 100-row batch unconditionally, that would over-trim in
|
||||||
|
// edge cases where just one or two rows are enough.
|
||||||
|
const needToFree = cachedSize - MAX_DEBUG_LOG_BYTES;
|
||||||
|
let freed = 0;
|
||||||
|
const idsToDelete = [];
|
||||||
|
for (const row of oldest) {
|
||||||
|
idsToDelete.push(row.id);
|
||||||
|
freed += Number(row.byte_size) || 0;
|
||||||
|
if (freed >= needToFree) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const tx = db.transaction((ids) => {
|
||||||
|
for (const id of ids) {
|
||||||
|
trimStatements.del.run({ id });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
tx(idsToDelete);
|
||||||
|
// Only decrement after the transaction has committed; a mid-batch failure
|
||||||
|
// would roll the DELETEs back and leave cachedSize untouched.
|
||||||
|
cachedSize -= freed;
|
||||||
|
if (freed === 0) {
|
||||||
|
// We deleted rows but they all had byte_size <= 0, so cachedSize did not
|
||||||
|
// move. Without intervention the outer loop would spin again with the same
|
||||||
|
// condition. Resync from the DB and bail to prevent that.
|
||||||
|
refreshSizeFromDb();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// SQLite rolled the batch back; resync cachedSize from the DB to recover from
|
||||||
|
// any unexpected drift, then bail out so we do not spin forever on a persistent
|
||||||
|
// failure (e.g. database is locked or read-only).
|
||||||
|
refreshSizeFromDb();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (cachedSize < 0) cachedSize = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Whether debug logging is currently enabled. Synchronous and cheap so the logger
|
||||||
|
* hot-path can call it on every log line.
|
||||||
|
*
|
||||||
|
* @returns {boolean} True if logs should be persisted to the debug_logs table.
|
||||||
|
*/
|
||||||
|
export function isEnabled() {
|
||||||
|
return cachedEnabled === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Append a single log entry to debug_logs (if enabled) and trim the rolling buffer if
|
||||||
|
* the new row pushes the live size above the cap.
|
||||||
|
*
|
||||||
|
* Safe to call even when logging is disabled, it becomes a no-op. Any storage error
|
||||||
|
* is swallowed so the logger never breaks the calling code; bookkeeping for cachedSize
|
||||||
|
* stays consistent because we update it only after a successful insert.
|
||||||
|
*
|
||||||
|
* @param {{ts:number, level:string, message:string}} entry
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function appendLogEntry(entry) {
|
||||||
|
if (!isEnabled()) return;
|
||||||
|
if (!entry || typeof entry.message !== 'string') return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const ts = Number.isFinite(entry.ts) ? entry.ts : Date.now();
|
||||||
|
const level = String(entry.level || 'info');
|
||||||
|
const message = entry.message;
|
||||||
|
const byte_size = byteLengthOf(message);
|
||||||
|
|
||||||
|
SqliteConnection.execute(
|
||||||
|
'INSERT INTO debug_logs (ts, level, message, byte_size) VALUES (@ts, @level, @message, @byte_size)',
|
||||||
|
{ ts, level, message, byte_size },
|
||||||
|
);
|
||||||
|
|
||||||
|
if (cachedSize == null) {
|
||||||
|
refreshSizeFromDb();
|
||||||
|
} else {
|
||||||
|
cachedSize += byte_size;
|
||||||
|
}
|
||||||
|
trimToFit();
|
||||||
|
} catch {
|
||||||
|
// Logging must never break the application. Swallow storage errors silently.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove every row from debug_logs and reset the cached size to zero. Used by both
|
||||||
|
* the "clear previous logs" path on (re)enable and by explicit clear actions.
|
||||||
|
*
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function clearAllDebugLogs() {
|
||||||
|
SqliteConnection.execute('DELETE FROM debug_logs');
|
||||||
|
cachedSize = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the cached live byte size of the debug_logs table contents.
|
||||||
|
* @returns {Promise<number>}
|
||||||
|
*/
|
||||||
|
export async function getCurrentSize() {
|
||||||
|
await ensureCachesInitialized();
|
||||||
|
return cachedSize ?? 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return the configured maximum size for the debug_logs table.
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
export function getMaxSize() {
|
||||||
|
return MAX_DEBUG_LOG_BYTES;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether the debug_logs table contains at least one row.
|
||||||
|
* @returns {boolean}
|
||||||
|
*/
|
||||||
|
export function hasAnyLogs() {
|
||||||
|
const row = SqliteConnection.query('SELECT 1 AS one FROM debug_logs LIMIT 1');
|
||||||
|
return Array.isArray(row) && row.length > 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Has debug logging ever been enabled in this installation? Used by the download
|
||||||
|
* endpoint to distinguish "no logs yet" (empty table) from "feature never used"
|
||||||
|
* (which returns 409 to surface a friendlier UI error).
|
||||||
|
*
|
||||||
|
* @returns {Promise<boolean>}
|
||||||
|
*/
|
||||||
|
export async function wasEverEnabled() {
|
||||||
|
const settings = await getSettings();
|
||||||
|
return settings[SETTING_EVER_ENABLED] === true;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn debug logging on. Persists both the active flag and the "ever enabled" flag,
|
||||||
|
* optionally clearing previous logs when the caller passes clearPrevious=true (this
|
||||||
|
* is the path taken when the UI confirm dialog "Delete previous logs?" is accepted).
|
||||||
|
*
|
||||||
|
* @param {object} [options]
|
||||||
|
* @param {boolean} [options.clearPrevious=false]
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function enableDebugLogging({ clearPrevious = false } = {}) {
|
||||||
|
if (clearPrevious) {
|
||||||
|
clearAllDebugLogs();
|
||||||
|
}
|
||||||
|
upsertSettings({ [SETTING_ENABLED]: true, [SETTING_EVER_ENABLED]: true });
|
||||||
|
cachedEnabled = true;
|
||||||
|
if (cachedSize == null) {
|
||||||
|
refreshSizeFromDb();
|
||||||
|
}
|
||||||
|
// Attach the logger sink only while recording is on so the logger hot path pays
|
||||||
|
// no per-call cost (Date.now + stringifyArgs) when nobody enabled the feature.
|
||||||
|
logger.setDebugLogSink(appendLogEntry);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Turn debug logging off. Previous logs are kept on disk so the user can still
|
||||||
|
* download them; they are only cleared when the user re-enables and chooses "delete
|
||||||
|
* previous logs".
|
||||||
|
*
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function disableDebugLogging() {
|
||||||
|
upsertSettings({ [SETTING_ENABLED]: false });
|
||||||
|
cachedEnabled = false;
|
||||||
|
// Detach the sink so the logger hot path returns immediately on its `if (sink)`
|
||||||
|
// check instead of paying the no-op cost on every log line.
|
||||||
|
logger.setDebugLogSink(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Return all stored log entries ordered chronologically. Used by the bundle builder
|
||||||
|
* when assembling logs.txt.
|
||||||
|
*
|
||||||
|
* @returns {{id:number, ts:number, level:string, message:string}[]}
|
||||||
|
*/
|
||||||
|
export function getAllDebugLogs() {
|
||||||
|
return SqliteConnection.query('SELECT id, ts, level, message FROM debug_logs ORDER BY id ASC');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reload the cached enabled flag from settings storage. Called from the logger at
|
||||||
|
* startup so the cache reflects the persisted state after a Fredy restart.
|
||||||
|
*
|
||||||
|
* @returns {Promise<boolean>} The active enabled flag.
|
||||||
|
*/
|
||||||
|
export async function reloadEnabledFromSettings() {
|
||||||
|
const settings = await getSettings();
|
||||||
|
cachedEnabled = settings[SETTING_ENABLED] === true;
|
||||||
|
// (Un)wire the sink to match the persisted state. Note: startup work that runs
|
||||||
|
// before index.js calls this (CloakBrowser binary check, runMigrations) still
|
||||||
|
// logs to stdout only, since the sink is not attached yet at that point.
|
||||||
|
if (cachedEnabled) {
|
||||||
|
logger.setDebugLogSink(appendLogEntry);
|
||||||
|
} else {
|
||||||
|
logger.setDebugLogSink(null);
|
||||||
|
}
|
||||||
|
return cachedEnabled;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Test-only helper to drop in-memory caches between unit tests. Resets every piece
|
||||||
|
* of module-scoped mutable state so a test that swaps the underlying DB does not
|
||||||
|
* inherit stale prepared statements from a previous run.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export function _resetForTests() {
|
||||||
|
cachedSize = null;
|
||||||
|
cachedEnabled = null;
|
||||||
|
trimStatements = null;
|
||||||
|
}
|
||||||
@@ -103,6 +103,13 @@ const EQUIPMENT_MAP = {
|
|||||||
lodgerflat: 'lodgerflat',
|
lodgerflat: 'lodgerflat',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The web UI uses "swapflat", but the mobile API only understands "swap_flat".
|
||||||
|
// An unknown value is not ignored: the API silently returns 0 results for the
|
||||||
|
// whole search. Other values (e.g. "projectlisting") are identical on both APIs.
|
||||||
|
const EXCLUSION_CRITERIA_MAP = {
|
||||||
|
swapflat: 'swap_flat',
|
||||||
|
};
|
||||||
|
|
||||||
const REAL_ESTATE_TYPE = {
|
const REAL_ESTATE_TYPE = {
|
||||||
'haus-mieten': 'houserent',
|
'haus-mieten': 'houserent',
|
||||||
'wohnung-mieten': 'apartmentrent',
|
'wohnung-mieten': 'apartmentrent',
|
||||||
@@ -251,6 +258,9 @@ export function convertWebToMobile(webUrl) {
|
|||||||
...(currentEquipmentParams ?? []),
|
...(currentEquipmentParams ?? []),
|
||||||
...items.map((item) => EQUIPMENT_MAP[item.toLowerCase()]).filter(Boolean),
|
...items.map((item) => EQUIPMENT_MAP[item.toLowerCase()]).filter(Boolean),
|
||||||
];
|
];
|
||||||
|
} else if (key === 'exclusioncriteria') {
|
||||||
|
const items = [].concat(val).flatMap((v) => `${v}`.split(','));
|
||||||
|
mobileParams[PARAM_NAME_MAP[key]] = items.map((item) => EXCLUSION_CRITERIA_MAP[item.toLowerCase()] ?? item);
|
||||||
} else {
|
} else {
|
||||||
mobileParams[PARAM_NAME_MAP[key]] = val;
|
mobileParams[PARAM_NAME_MAP[key]] = val;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,7 +104,6 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
|||||||
logger.debug('Working hours set. Skipping as outside of working hours.');
|
logger.debug('Working hours set. Skipping as outside of working hours.');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
settings.lastRun = now;
|
|
||||||
const jobs = jobStorage.getJobs().filter((job) => {
|
const jobs = jobStorage.getJobs().filter((job) => {
|
||||||
if (!context) return true; // startup/cron → all
|
if (!context) return true; // startup/cron → all
|
||||||
if (context.isAdmin) return true; // admin → all
|
if (context.isAdmin) return true; // admin → all
|
||||||
@@ -150,6 +149,13 @@ export function initJobExecutionService({ providers, settings, intervalMs }) {
|
|||||||
}
|
}
|
||||||
const acquired = markRunning(job.id);
|
const acquired = markRunning(job.id);
|
||||||
if (!acquired) return;
|
if (!acquired) return;
|
||||||
|
// Persist the trigger time so the dashboard "last search" KPI can be
|
||||||
|
// derived per accessible user without an in-memory cache.
|
||||||
|
try {
|
||||||
|
jobStorage.updateJobLastRunAt(job.id, Date.now());
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to persist last_run_at for job', job.id, err);
|
||||||
|
}
|
||||||
// notify listeners (SSE) that the job started
|
// notify listeners (SSE) that the job started
|
||||||
try {
|
try {
|
||||||
bus.emit('jobs:status', { jobId: job.id, running: true });
|
bus.emit('jobs:status', { jobId: job.id, running: true });
|
||||||
|
|||||||
@@ -17,16 +17,16 @@ const userAgents = [
|
|||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if a listing is still active with up to 5 attempts and exponential backoff.
|
* Check if a listing is still active with up to `maxAttempts` attempts and exponential backoff.
|
||||||
* Backoff waits are randomized and capped.
|
* Backoff waits are randomized and capped.
|
||||||
*
|
*
|
||||||
* Rules:
|
* Rules:
|
||||||
* - HTTP 200 => return 1 (if checkForText is provided and found, returns 0)
|
* - HTTP 200 => return 1 (if checkForText is provided and found, returns 0)
|
||||||
* - HTTP 401/403 => return -1 (most certainly detected as a bot)
|
* - HTTP 401/403 => return -1 (most certainly detected as a bot)
|
||||||
* - HTTP 404 => return 0
|
* - HTTP 404/410 => return 0
|
||||||
* - Other statuses or network errors => retry until attempts are exhausted
|
* - Other statuses or network errors => retry until attempts are exhausted
|
||||||
*
|
*
|
||||||
* @returns {Promise<Integer>} 1 if active, 0 if not active and -1 if detected as bot
|
* @returns {Promise<number>} 1 if active, 0 if not active and -1 if detected as bot
|
||||||
*/
|
*/
|
||||||
export default async function checkIfListingIsActive(link, checkForText = null) {
|
export default async function checkIfListingIsActive(link, checkForText = null) {
|
||||||
await sleep(randomBetween(50, 100));
|
await sleep(randomBetween(50, 100));
|
||||||
|
|||||||
@@ -14,6 +14,20 @@ const COLORS = {
|
|||||||
const env = process.env.NODE_ENV || 'development';
|
const env = process.env.NODE_ENV || 'development';
|
||||||
const useColor = process.stdout.isTTY || process.stderr.isTTY;
|
const useColor = process.stdout.isTTY || process.stderr.isTTY;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Optional sink that forwards formatted log entries to the opt-in "Debug Logging"
|
||||||
|
* DB storage. Wired and unwired by debugLogStorage as the feature is toggled, so
|
||||||
|
* when nobody enabled the feature this stays null and the logger hot path skips
|
||||||
|
* the Date.now + stringifyArgs work entirely.
|
||||||
|
*
|
||||||
|
* We deliberately do NOT import debugLogStorage here, because that would create a
|
||||||
|
* cycle (debugLogStorage → SqliteConnection → utils → logger → debugLogStorage).
|
||||||
|
* Inversion of control via setDebugLogSink() keeps the dependency one-way.
|
||||||
|
*
|
||||||
|
* @type {((entry:{ts:number, level:string, message:string}) => void)|null}
|
||||||
|
*/
|
||||||
|
let debugLogSink = null;
|
||||||
|
|
||||||
function ts() {
|
function ts() {
|
||||||
const d = new Date();
|
const d = new Date();
|
||||||
const yyyy = d.getFullYear();
|
const yyyy = d.getFullYear();
|
||||||
@@ -31,10 +45,50 @@ function lvl(level) {
|
|||||||
return `${COLORS[level] || ''}${upper}${COLORS.reset}`;
|
return `${COLORS[level] || ''}${upper}${COLORS.reset}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build a colour-free plain text representation of variadic console args. Errors
|
||||||
|
* are unwrapped to their stack/message, objects are JSON-serialized. Used when
|
||||||
|
* forwarding to the DB sink so the stored text is portable across terminals.
|
||||||
|
*
|
||||||
|
* @param {any[]} args
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function stringifyArgs(args) {
|
||||||
|
return args
|
||||||
|
.map((a) => {
|
||||||
|
if (a == null) return String(a);
|
||||||
|
if (a instanceof Error) return a.stack || a.message;
|
||||||
|
if (typeof a === 'object') {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(a);
|
||||||
|
} catch {
|
||||||
|
return String(a);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return String(a);
|
||||||
|
})
|
||||||
|
.join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
/* eslint-disable no-console */
|
/* eslint-disable no-console */
|
||||||
function log(level, ...args) {
|
function log(level, ...args) {
|
||||||
|
// Forward to the DB sink first (regardless of console suppression rules) so the
|
||||||
|
// recorded debug bundle truly contains every level, including debug entries that
|
||||||
|
// would otherwise be silenced in production.
|
||||||
|
if (debugLogSink) {
|
||||||
|
try {
|
||||||
|
debugLogSink({
|
||||||
|
ts: Date.now(),
|
||||||
|
level,
|
||||||
|
message: `${stringifyArgs(args)}`,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// never break the caller because of logging
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (level === 'debug' && env !== 'development') {
|
if (level === 'debug' && env !== 'development') {
|
||||||
return; // Skip debug logs in non-development environments
|
return; // Skip debug logs in non-development environments (console only)
|
||||||
}
|
}
|
||||||
|
|
||||||
const prefix = `[${ts()}] ${lvl(level)}:`;
|
const prefix = `[${ts()}] ${lvl(level)}:`;
|
||||||
@@ -56,9 +110,28 @@ function log(level, ...args) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register a sink function that receives every log entry the logger sees, regardless
|
||||||
|
* of console suppression rules. debugLogStorage attaches its sink only while the
|
||||||
|
* feature is enabled and detaches it on disable, so the logger's hot path can use
|
||||||
|
* the null check as a cheap on/off gate and skip stringification when off.
|
||||||
|
*
|
||||||
|
* Pass null to remove the sink (used both by the storage module on disable and by
|
||||||
|
* tests to reset state between cases).
|
||||||
|
*
|
||||||
|
* @param {((entry:{ts:number, level:string, message:string}) => void)|null} sink
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
function setDebugLogSink(sink) {
|
||||||
|
debugLogSink = typeof sink === 'function' ? sink : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export { setDebugLogSink };
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
debug: (...a) => log('debug', ...a),
|
debug: (...a) => log('debug', ...a),
|
||||||
info: (...a) => log('info', ...a),
|
info: (...a) => log('info', ...a),
|
||||||
warn: (...a) => log('warn', ...a),
|
warn: (...a) => log('warn', ...a),
|
||||||
error: (...a) => log('error', ...a),
|
error: (...a) => log('error', ...a),
|
||||||
|
setDebugLogSink,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -40,7 +40,8 @@ class SqliteConnection {
|
|||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Returns a singleton instance of better-sqlite3 Database.
|
* Returns a singleton instance of better-sqlite3 Database.
|
||||||
* Respects env var SQLITE_DB_PATH and defaults to db/listings.db.
|
* Uses the configured `sqlitepath` (from conf/config.json) as the directory,
|
||||||
|
* defaulting to `/db` (relative to the project root) when unset.
|
||||||
*/
|
*/
|
||||||
static getConnection() {
|
static getConnection() {
|
||||||
if (this.#db) return this.#db;
|
if (this.#db) return this.#db;
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export const getJob = (jobId) => {
|
|||||||
j.notification_adapter AS notificationAdapter,
|
j.notification_adapter AS notificationAdapter,
|
||||||
j.spatial_filter AS spatialFilter,
|
j.spatial_filter AS spatialFilter,
|
||||||
j.spec_filter AS specFilter,
|
j.spec_filter AS specFilter,
|
||||||
|
j.last_run_at AS lastRunAt,
|
||||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||||
FROM jobs j
|
FROM jobs j
|
||||||
WHERE j.id = @id
|
WHERE j.id = @id
|
||||||
@@ -116,6 +117,24 @@ export const getJob = (jobId) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Record the timestamp at which a job was last triggered.
|
||||||
|
*
|
||||||
|
* Called from the job execution service when a job starts running. The value
|
||||||
|
* is persisted so that the dashboard "last search" KPI survives restarts and
|
||||||
|
* can be computed per accessible user.
|
||||||
|
*
|
||||||
|
* @param {string} jobId - Job primary key.
|
||||||
|
* @param {number} timestamp - Epoch milliseconds.
|
||||||
|
* @returns {void}
|
||||||
|
*/
|
||||||
|
export const updateJobLastRunAt = (jobId, timestamp) => {
|
||||||
|
SqliteConnection.execute(`UPDATE jobs SET last_run_at = @timestamp WHERE id = @id`, {
|
||||||
|
id: jobId,
|
||||||
|
timestamp,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Update job enabled status.
|
* Update job enabled status.
|
||||||
* @param {{jobId: string, status: boolean}} params - Parameters.
|
* @param {{jobId: string, status: boolean}} params - Parameters.
|
||||||
@@ -150,9 +169,17 @@ export const removeJobsByUserId = (userId) => {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Get all jobs.
|
* Get all jobs.
|
||||||
|
*
|
||||||
|
* By default only enabled jobs are returned, since most callers (scheduler,
|
||||||
|
* geocoding cron, tracker, dashboard) operate on active jobs only. The UI,
|
||||||
|
* however, must also be able to load disabled jobs (e.g. to edit them or view
|
||||||
|
* their listings), so it passes `includeDisabled: true`.
|
||||||
|
*
|
||||||
|
* @param {Object} [params]
|
||||||
|
* @param {boolean} [params.includeDisabled=false] - When true, disabled jobs are included.
|
||||||
* @returns {Job[]} List of jobs ordered by name (NULLs last).
|
* @returns {Job[]} List of jobs ordered by name (NULLs last).
|
||||||
*/
|
*/
|
||||||
export const getJobs = () => {
|
export const getJobs = ({ includeDisabled = false } = {}) => {
|
||||||
const rows = SqliteConnection.query(
|
const rows = SqliteConnection.query(
|
||||||
`SELECT j.id,
|
`SELECT j.id,
|
||||||
j.user_id AS userId,
|
j.user_id AS userId,
|
||||||
@@ -164,9 +191,10 @@ export const getJobs = () => {
|
|||||||
j.notification_adapter AS notificationAdapter,
|
j.notification_adapter AS notificationAdapter,
|
||||||
j.spatial_filter AS spatialFilter,
|
j.spatial_filter AS spatialFilter,
|
||||||
j.spec_filter AS specFilter,
|
j.spec_filter AS specFilter,
|
||||||
|
j.last_run_at AS lastRunAt,
|
||||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||||
FROM jobs j
|
FROM jobs j
|
||||||
WHERE j.enabled = 1
|
${includeDisabled ? '' : 'WHERE j.enabled = 1'}
|
||||||
ORDER BY j.name IS NULL, j.name`,
|
ORDER BY j.name IS NULL, j.name`,
|
||||||
);
|
);
|
||||||
return rows.map((row) => ({
|
return rows.map((row) => ({
|
||||||
@@ -269,6 +297,7 @@ export const queryJobs = ({
|
|||||||
j.notification_adapter AS notificationAdapter,
|
j.notification_adapter AS notificationAdapter,
|
||||||
j.spatial_filter AS spatialFilter,
|
j.spatial_filter AS spatialFilter,
|
||||||
j.spec_filter AS specFilter,
|
j.spec_filter AS specFilter,
|
||||||
|
j.last_run_at AS lastRunAt,
|
||||||
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
(SELECT COUNT(1) FROM listings l WHERE l.job_id = j.id AND l.is_active = 1 AND l.manually_deleted = 0) AS numberOfFoundListings
|
||||||
FROM jobs j
|
FROM jobs j
|
||||||
${whereSql}
|
${whereSql}
|
||||||
|
|||||||
@@ -264,6 +264,7 @@ export const storeListings = (jobId, providerId, listings) => {
|
|||||||
* @param {number} [params.createdBefore] - Only include listings created at or before this unix timestamp (ms).
|
* @param {number} [params.createdBefore] - Only include listings created at or before this unix timestamp (ms).
|
||||||
* @param {string} [params.userId] - Current user id used to scope listings (ignored for admins).
|
* @param {string} [params.userId] - Current user id used to scope listings (ignored for admins).
|
||||||
* @param {boolean} [params.isAdmin=false] - When true, returns all listings.
|
* @param {boolean} [params.isAdmin=false] - When true, returns all listings.
|
||||||
|
* @param {boolean} [params.hiddenOnly=false] - When true, returns only soft-deleted (manually_deleted = 1) listings.
|
||||||
* @returns {{ totalNumber:number, page:number, result:Object[] }}
|
* @returns {{ totalNumber:number, page:number, result:Object[] }}
|
||||||
*/
|
*/
|
||||||
export const queryListings = ({
|
export const queryListings = ({
|
||||||
@@ -284,6 +285,7 @@ export const queryListings = ({
|
|||||||
maxPrice = null,
|
maxPrice = null,
|
||||||
userId = null,
|
userId = null,
|
||||||
isAdmin = false,
|
isAdmin = false,
|
||||||
|
hiddenOnly = false,
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
// sanitize inputs
|
// sanitize inputs
|
||||||
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(1000, Math.floor(pageSize)) : 50;
|
const safePageSize = Number.isFinite(pageSize) && pageSize > 0 ? Math.min(1000, Math.floor(pageSize)) : 50;
|
||||||
@@ -365,8 +367,8 @@ export const queryListings = ({
|
|||||||
whereParts.push('(l.price <= @maxPrice)');
|
whereParts.push('(l.price <= @maxPrice)');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build whereSql (filtering by manually_deleted = 0)
|
// Build whereSql: in normal mode hide soft-deleted; in hiddenOnly mode show only soft-deleted.
|
||||||
whereParts.push('(l.manually_deleted = 0)');
|
whereParts.push(hiddenOnly ? '(l.manually_deleted = 1)' : '(l.manually_deleted = 0)');
|
||||||
|
|
||||||
const whereSqlWithAlias = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
|
const whereSqlWithAlias = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||||
|
|
||||||
@@ -463,6 +465,23 @@ export const deleteListingsById = (ids, hardDelete = false) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Restore previously soft-deleted listings by clearing their `manually_deleted` flag.
|
||||||
|
*
|
||||||
|
* @param {string[]} ids - Array of DB row IDs to restore.
|
||||||
|
* @returns {any} The result from SqliteConnection.execute.
|
||||||
|
*/
|
||||||
|
export const restoreListingsById = (ids) => {
|
||||||
|
if (!Array.isArray(ids) || ids.length === 0) return;
|
||||||
|
const placeholders = ids.map(() => '?').join(',');
|
||||||
|
return SqliteConnection.execute(
|
||||||
|
`UPDATE listings
|
||||||
|
SET manually_deleted = 0
|
||||||
|
WHERE id IN (${placeholders})`,
|
||||||
|
ids,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return all listings that are active, have an address, and do not yet have geocoordinates.
|
* Return all listings that are active, have an address, and do not yet have geocoordinates.
|
||||||
*
|
*
|
||||||
|
|||||||
32
lib/services/storage/migrations/sql/20.add-debug-logs.js
Normal file
32
lib/services/storage/migrations/sql/20.add-debug-logs.js
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration: create the debug_logs table used by the opt-in "Debug Logging" feature.
|
||||||
|
*
|
||||||
|
* Each row is a single log line (timestamp + level + message) captured by the in-app
|
||||||
|
* logger while debug logging is enabled. We store the UTF-8 byte size of the message
|
||||||
|
* alongside the row so the debugLogStorage can maintain a rolling 5 MB cap without
|
||||||
|
* having to run length() / SUM() on every insert.
|
||||||
|
*
|
||||||
|
* The "debug_logging_enabled" and "debug_logging_ever_enabled" flags are persisted in
|
||||||
|
* the existing settings table (no schema change needed there) and are managed by
|
||||||
|
* debugLogStorage.js at runtime.
|
||||||
|
*/
|
||||||
|
export function up(db) {
|
||||||
|
// id is INTEGER PRIMARY KEY AUTOINCREMENT, which is an alias for SQLite's rowid and
|
||||||
|
// is implicitly indexed. No additional index needed; selecting / deleting by id and
|
||||||
|
// ordering by id ASC (rolling buffer) both use the existing rowid index.
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE IF NOT EXISTS debug_logs
|
||||||
|
(
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
level TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
byte_size INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Migration: add `last_run_at` to the `jobs` table.
|
||||||
|
*
|
||||||
|
* Stores the epoch-ms timestamp at which a job was last triggered. Used by the
|
||||||
|
* dashboard "last search" KPI so the value survives restarts and reflects the
|
||||||
|
* actual jobs the requesting user can see (own, shared, or all for admins),
|
||||||
|
* replacing the previous in-memory `settings.lastRun` value.
|
||||||
|
*
|
||||||
|
* NULL means the job has not yet been triggered since this column was added.
|
||||||
|
*/
|
||||||
|
export function up(db) {
|
||||||
|
db.exec(`
|
||||||
|
ALTER TABLE jobs ADD COLUMN last_run_at INTEGER
|
||||||
|
`);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import { getSettings } from '../storage/settingsStorage.js';
|
|||||||
const deviceId = getUniqueId() || 'N/A';
|
const deviceId = getUniqueId() || 'N/A';
|
||||||
const version = await getPackageVersion();
|
const version = await getPackageVersion();
|
||||||
const FREDY_TRACKING_URL = 'https://fredy.orange-coding.net/tracking';
|
const FREDY_TRACKING_URL = 'https://fredy.orange-coding.net/tracking';
|
||||||
|
const TRACKING_CATEGORY = 'fredy';
|
||||||
const isDocker = process.env.IS_DOCKER != null;
|
const isDocker = process.env.IS_DOCKER != null;
|
||||||
|
|
||||||
const staticTrackingData = {
|
const staticTrackingData = {
|
||||||
@@ -95,6 +96,7 @@ async function enrichTrackingObject(trackingObject) {
|
|||||||
const settings = await getSettings();
|
const settings = await getSettings();
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
category: TRACKING_CATEGORY,
|
||||||
...trackingObject,
|
...trackingObject,
|
||||||
...staticTrackingData,
|
...staticTrackingData,
|
||||||
isDemo: settings.demoMode,
|
isDemo: settings.demoMode,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@
|
|||||||
* @property {SpatialFilter | null} [spatialFilter] Optional spatial filter configuration as GeoJSON FeatureCollection.
|
* @property {SpatialFilter | null} [spatialFilter] Optional spatial filter configuration as GeoJSON FeatureCollection.
|
||||||
* @property {SpecFilter | null} [specFilter] Optional listing specifications.
|
* @property {SpecFilter | null} [specFilter] Optional listing specifications.
|
||||||
* @property {number} [numberOfFoundListings] Count of active listings for this job.
|
* @property {number} [numberOfFoundListings] Count of active listings for this job.
|
||||||
|
* @property {number | null} [lastRunAt] Epoch ms at which the job was last triggered, or null if never triggered.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export {};
|
export {};
|
||||||
|
|||||||
@@ -5,12 +5,13 @@
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Extract the first number from a string like "1.234 €" or "70 m²".
|
* Extract the first number from a string like "1.234 €" or "70 m²".
|
||||||
* Removes dots/commas before parsing. Returns null on invalid input.
|
* Removes dots/commas before parsing. Returns null when the input is
|
||||||
|
* null/undefined or cannot be parsed into a number.
|
||||||
* @param {string|undefined|null} str
|
* @param {string|undefined|null} str
|
||||||
* @returns {number|null}
|
* @returns {number|null}
|
||||||
*/
|
*/
|
||||||
export const extractNumber = (str) => {
|
export const extractNumber = (str) => {
|
||||||
if (str == null) return 0;
|
if (str == null) return null;
|
||||||
if (typeof str === 'number') return str;
|
if (typeof str === 'number') return str;
|
||||||
const cleaned = str.replace(/\./g, '').replace(',', '.');
|
const cleaned = str.replace(/\./g, '').replace(',', '.');
|
||||||
const num = parseFloat(cleaned);
|
const num = parseFloat(cleaned);
|
||||||
|
|||||||
27
package.json
27
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "22.3.3",
|
"version": "22.9.1",
|
||||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
"description": "Fredy - [F]ind [R]eal [E]state [D]amn Eas[y] - Fredy keeps searching for new apartments, houses, and flats in Germany on platforms like ImmoScout24, Immowelt, Immonet, eBay Kleinanzeigen, and WG-Gesucht and instantly delivers the results to you via Slack, Telegram, Email, Discord or ntfy, so you can focus on the more important things in life ;)",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
"start:backend": "x-var NODE_ENV=production node index.js",
|
"start:backend": "x-var NODE_ENV=production node index.js",
|
||||||
@@ -42,6 +42,7 @@
|
|||||||
"house",
|
"house",
|
||||||
"rent",
|
"rent",
|
||||||
"immoscout",
|
"immoscout",
|
||||||
|
"kleinanzeigen",
|
||||||
"scraper",
|
"scraper",
|
||||||
"immonet",
|
"immonet",
|
||||||
"immowelt",
|
"immowelt",
|
||||||
@@ -62,9 +63,9 @@
|
|||||||
"Firefox ESR"
|
"Firefox ESR"
|
||||||
],
|
],
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@douyinfe/semi-icons": "^2.99.3",
|
"@douyinfe/semi-icons": "^2.100.0",
|
||||||
"@douyinfe/semi-ui": "2.99.3",
|
"@douyinfe/semi-ui": "2.100.0",
|
||||||
"@douyinfe/semi-ui-19": "^2.99.3",
|
"@douyinfe/semi-ui-19": "^2.100.0",
|
||||||
"@fastify/cookie": "^11.0.2",
|
"@fastify/cookie": "^11.0.2",
|
||||||
"@fastify/helmet": "^13.0.2",
|
"@fastify/helmet": "^13.0.2",
|
||||||
"@fastify/session": "^11.1.1",
|
"@fastify/session": "^11.1.1",
|
||||||
@@ -75,7 +76,7 @@
|
|||||||
"@turf/boolean-point-in-polygon": "^7.3.5",
|
"@turf/boolean-point-in-polygon": "^7.3.5",
|
||||||
"@vitejs/plugin-react": "6.0.2",
|
"@vitejs/plugin-react": "6.0.2",
|
||||||
"adm-zip": "^0.5.17",
|
"adm-zip": "^0.5.17",
|
||||||
"better-sqlite3": "^12.10.0",
|
"better-sqlite3": "^12.10.1",
|
||||||
"chart.js": "^4.5.1",
|
"chart.js": "^4.5.1",
|
||||||
"cheerio": "^1.2.0",
|
"cheerio": "^1.2.0",
|
||||||
"cloakbrowser": "^0.3.31",
|
"cloakbrowser": "^0.3.31",
|
||||||
@@ -86,7 +87,7 @@
|
|||||||
"node-cron": "^4.2.1",
|
"node-cron": "^4.2.1",
|
||||||
"node-fetch": "3.3.2",
|
"node-fetch": "3.3.2",
|
||||||
"node-mailjet": "6.0.11",
|
"node-mailjet": "6.0.11",
|
||||||
"nodemailer": "^8.0.10",
|
"nodemailer": "^8.0.11",
|
||||||
"p-throttle": "^8.1.0",
|
"p-throttle": "^8.1.0",
|
||||||
"package-up": "^5.0.0",
|
"package-up": "^5.0.0",
|
||||||
"puppeteer-core": "^25.1.0",
|
"puppeteer-core": "^25.1.0",
|
||||||
@@ -95,10 +96,10 @@
|
|||||||
"react-chartjs-2": "^5.3.1",
|
"react-chartjs-2": "^5.3.1",
|
||||||
"react-dom": "19.2.7",
|
"react-dom": "19.2.7",
|
||||||
"react-range-slider-input": "^3.3.5",
|
"react-range-slider-input": "^3.3.5",
|
||||||
"react-router": "7.16.0",
|
"react-router": "7.17.0",
|
||||||
"react-router-dom": "7.16.0",
|
"react-router-dom": "7.17.0",
|
||||||
"resend": "^6.12.4",
|
"resend": "^6.12.4",
|
||||||
"semver": "^7.8.1",
|
"semver": "^7.8.4",
|
||||||
"slack": "11.0.2",
|
"slack": "11.0.2",
|
||||||
"vite": "8.0.16",
|
"vite": "8.0.16",
|
||||||
"x-var": "^3.0.1",
|
"x-var": "^3.0.1",
|
||||||
@@ -111,16 +112,16 @@
|
|||||||
"@babel/preset-react": "7.29.7",
|
"@babel/preset-react": "7.29.7",
|
||||||
"@eslint/js": "^10.0.1",
|
"@eslint/js": "^10.0.1",
|
||||||
"chalk": "^5.6.2",
|
"chalk": "^5.6.2",
|
||||||
"eslint": "10.4.1",
|
"eslint": "10.5.0",
|
||||||
"eslint-config-prettier": "10.1.8",
|
"eslint-config-prettier": "10.1.8",
|
||||||
"eslint-plugin-react": "7.37.5",
|
"eslint-plugin-react": "7.37.5",
|
||||||
"globals": "^17.6.0",
|
"globals": "^17.6.0",
|
||||||
"history": "5.3.0",
|
"history": "5.3.0",
|
||||||
"husky": "9.1.7",
|
"husky": "9.1.7",
|
||||||
"less": "4.6.4",
|
"less": "4.6.6",
|
||||||
"lint-staged": "17.0.7",
|
"lint-staged": "17.0.7",
|
||||||
"nodemon": "^3.1.14",
|
"nodemon": "^3.1.14",
|
||||||
"prettier": "3.8.3",
|
"prettier": "3.8.4",
|
||||||
"vitest": "^4.1.8"
|
"vitest": "^4.1.8"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,8 +17,12 @@ export const getGeocoordinatesByAddress = (any) => {
|
|||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let userSettings = null;
|
||||||
|
export function setUserSettings(settings) {
|
||||||
|
userSettings = settings;
|
||||||
|
}
|
||||||
export function getUserSettings(userId) {
|
export function getUserSettings(userId) {
|
||||||
return null;
|
return userSettings;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getSettings() {
|
export async function getSettings() {
|
||||||
|
|||||||
@@ -3,9 +3,10 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { expect } from 'vitest';
|
import { afterEach, expect } from 'vitest';
|
||||||
import { mockFredy } from './utils.js';
|
import { mockFredy } from './utils.js';
|
||||||
import * as mockStore from './mocks/mockStore.js';
|
import * as mockStore from './mocks/mockStore.js';
|
||||||
|
import { get as getLastNotification } from './mocks/mockNotification.js';
|
||||||
|
|
||||||
describe('Issue reproduction: listings filtered by similarity or area should be marked as manually deleted', () => {
|
describe('Issue reproduction: listings filtered by similarity or area should be marked as manually deleted', () => {
|
||||||
it('should call deleteListingsById when listings are filtered by similarity', async () => {
|
it('should call deleteListingsById when listings are filtered by similarity', async () => {
|
||||||
@@ -113,3 +114,223 @@ describe('Issue reproduction: listings filtered by similarity or area should be
|
|||||||
expect(mockStore.deletedIds).toContain('2');
|
expect(mockStore.deletedIds).toContain('2');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Blacklist is re-applied after detail enrichment', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
mockStore.setUserSettings(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out a listing whose blacklisted term only appears in the enriched description', async () => {
|
||||||
|
const Fredy = await mockFredy();
|
||||||
|
const providerId = 'test-provider';
|
||||||
|
|
||||||
|
mockStore.setUserSettings({
|
||||||
|
provider_details: [providerId],
|
||||||
|
blacklist_filter_on_provider_details: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockSimilarityCache = {
|
||||||
|
checkAndAddEntry: () => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const blacklist = ['allkauf'];
|
||||||
|
|
||||||
|
// The search results page returns a clean snippet (no blacklisted term).
|
||||||
|
// fetchDetails simulates loading the full detail page and discovers the
|
||||||
|
// blacklisted term hidden deep in the description.
|
||||||
|
const providerConfig = {
|
||||||
|
url: 'http://example.com',
|
||||||
|
getListings: () =>
|
||||||
|
Promise.resolve([
|
||||||
|
{
|
||||||
|
id: 'kept',
|
||||||
|
title: 'Nice house',
|
||||||
|
address: 'Some street',
|
||||||
|
price: '500000',
|
||||||
|
link: 'http://example.com/kept',
|
||||||
|
description: 'Cozy home with garden',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'blacklisted',
|
||||||
|
title: 'Eleganz trifft Raumkomfort',
|
||||||
|
address: 'Other street',
|
||||||
|
price: '600000',
|
||||||
|
link: 'http://example.com/blacklisted',
|
||||||
|
description: 'Eleganz trifft Raumkomfort',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
normalize: (l) => l,
|
||||||
|
filter: (l) => {
|
||||||
|
const text = `${l.title ?? ''} ${l.description ?? ''}`.toLowerCase();
|
||||||
|
return !blacklist.some((term) => text.includes(term));
|
||||||
|
},
|
||||||
|
fetchDetails: (listing) => {
|
||||||
|
if (listing.id === 'blacklisted') {
|
||||||
|
return Promise.resolve({
|
||||||
|
...listing,
|
||||||
|
description: 'Mit allkauf Haus wird dein Traum vom Eigenheim wahr.',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return Promise.resolve(listing);
|
||||||
|
},
|
||||||
|
crawlFields: {
|
||||||
|
id: 'id',
|
||||||
|
title: 'title',
|
||||||
|
address: 'address',
|
||||||
|
price: 'price',
|
||||||
|
link: 'link',
|
||||||
|
description: 'description',
|
||||||
|
},
|
||||||
|
requiredFieldNames: ['id', 'title', 'address', 'price', 'link', 'description'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockedJob = {
|
||||||
|
id: 'blacklist-test-job',
|
||||||
|
notificationAdapter: null,
|
||||||
|
specFilter: null,
|
||||||
|
spatialFilter: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fredy = new Fredy(providerConfig, mockedJob, providerId, mockSimilarityCache, undefined);
|
||||||
|
|
||||||
|
const result = await fredy.execute();
|
||||||
|
|
||||||
|
expect(result).toBeInstanceOf(Array);
|
||||||
|
const ids = result.map((l) => l.id);
|
||||||
|
expect(ids).toContain('kept');
|
||||||
|
expect(ids).not.toContain('blacklisted');
|
||||||
|
|
||||||
|
const notification = getLastNotification();
|
||||||
|
const notifiedIds = (notification?.payload ?? []).map((p) => p.id);
|
||||||
|
expect(notifiedIds).not.toContain('blacklisted');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('short-circuits the pipeline when all listings get blacklisted after enrichment', async () => {
|
||||||
|
const Fredy = await mockFredy();
|
||||||
|
const providerId = 'all-blacklisted-provider';
|
||||||
|
|
||||||
|
mockStore.setUserSettings({
|
||||||
|
provider_details: [providerId],
|
||||||
|
blacklist_filter_on_provider_details: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockSimilarityCache = {
|
||||||
|
checkAndAddEntry: () => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const blacklist = ['allkauf'];
|
||||||
|
|
||||||
|
const providerConfig = {
|
||||||
|
url: 'http://example.com',
|
||||||
|
getListings: () =>
|
||||||
|
Promise.resolve([
|
||||||
|
{
|
||||||
|
id: 'only',
|
||||||
|
title: 'Eleganz trifft Raumkomfort',
|
||||||
|
address: 'Some street',
|
||||||
|
price: '700000',
|
||||||
|
link: 'http://example.com/only',
|
||||||
|
description: 'Eleganz trifft Raumkomfort',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
normalize: (l) => l,
|
||||||
|
filter: (l) => {
|
||||||
|
const text = `${l.title ?? ''} ${l.description ?? ''}`.toLowerCase();
|
||||||
|
return !blacklist.some((term) => text.includes(term));
|
||||||
|
},
|
||||||
|
fetchDetails: (listing) =>
|
||||||
|
Promise.resolve({
|
||||||
|
...listing,
|
||||||
|
description: 'Mit allkauf Haus wird dein Traum vom Eigenheim wahr.',
|
||||||
|
}),
|
||||||
|
crawlFields: {
|
||||||
|
id: 'id',
|
||||||
|
title: 'title',
|
||||||
|
address: 'address',
|
||||||
|
price: 'price',
|
||||||
|
link: 'link',
|
||||||
|
description: 'description',
|
||||||
|
},
|
||||||
|
requiredFieldNames: ['id', 'title', 'address', 'price', 'link', 'description'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockedJob = {
|
||||||
|
id: 'all-blacklisted-job',
|
||||||
|
notificationAdapter: null,
|
||||||
|
specFilter: null,
|
||||||
|
spatialFilter: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fredy = new Fredy(providerConfig, mockedJob, providerId, mockSimilarityCache, undefined);
|
||||||
|
|
||||||
|
// Should resolve to undefined (NoNewListingsWarning is caught in _handleError).
|
||||||
|
const result = await fredy.execute();
|
||||||
|
expect(result).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does NOT re-filter when blacklist_filter_on_provider_details is disabled', async () => {
|
||||||
|
const Fredy = await mockFredy();
|
||||||
|
const providerId = 'opt-out-provider';
|
||||||
|
|
||||||
|
// provider_details enabled (so fetchDetails runs) but blacklist re-filter NOT enabled.
|
||||||
|
mockStore.setUserSettings({
|
||||||
|
provider_details: [providerId],
|
||||||
|
blacklist_filter_on_provider_details: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const mockSimilarityCache = {
|
||||||
|
checkAndAddEntry: () => false,
|
||||||
|
};
|
||||||
|
|
||||||
|
const blacklist = ['allkauf'];
|
||||||
|
|
||||||
|
const providerConfig = {
|
||||||
|
url: 'http://example.com',
|
||||||
|
getListings: () =>
|
||||||
|
Promise.resolve([
|
||||||
|
{
|
||||||
|
id: 'leaks-through',
|
||||||
|
title: 'Eleganz trifft Raumkomfort',
|
||||||
|
address: 'Other street',
|
||||||
|
price: '600000',
|
||||||
|
link: 'http://example.com/leaks-through',
|
||||||
|
description: 'Eleganz trifft Raumkomfort',
|
||||||
|
},
|
||||||
|
]),
|
||||||
|
normalize: (l) => l,
|
||||||
|
filter: (l) => {
|
||||||
|
const text = `${l.title ?? ''} ${l.description ?? ''}`.toLowerCase();
|
||||||
|
return !blacklist.some((term) => text.includes(term));
|
||||||
|
},
|
||||||
|
fetchDetails: (listing) =>
|
||||||
|
Promise.resolve({
|
||||||
|
...listing,
|
||||||
|
description: 'Mit allkauf Haus wird dein Traum vom Eigenheim wahr.',
|
||||||
|
}),
|
||||||
|
crawlFields: {
|
||||||
|
id: 'id',
|
||||||
|
title: 'title',
|
||||||
|
address: 'address',
|
||||||
|
price: 'price',
|
||||||
|
link: 'link',
|
||||||
|
description: 'description',
|
||||||
|
},
|
||||||
|
requiredFieldNames: ['id', 'title', 'address', 'price', 'link', 'description'],
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockedJob = {
|
||||||
|
id: 'opt-out-job',
|
||||||
|
notificationAdapter: null,
|
||||||
|
specFilter: null,
|
||||||
|
spatialFilter: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
const fredy = new Fredy(providerConfig, mockedJob, providerId, mockSimilarityCache, undefined);
|
||||||
|
|
||||||
|
const result = await fredy.execute();
|
||||||
|
|
||||||
|
// Listing leaks through because user has not opted in to the stricter check.
|
||||||
|
expect(result).toBeInstanceOf(Array);
|
||||||
|
expect(result.map((l) => l.id)).toContain('leaks-through');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -57,13 +57,17 @@ describe('#sparkasse testsuite()', () => {
|
|||||||
expect(notify.id).toBeTypeOf('string');
|
expect(notify.id).toBeTypeOf('string');
|
||||||
expect(notify.price).toBeTypeOf('string');
|
expect(notify.price).toBeTypeOf('string');
|
||||||
expect(notify.price).toContain('€');
|
expect(notify.price).toContain('€');
|
||||||
|
// Size can legitimately be absent for a card whose layout shifts the
|
||||||
|
// value out of the expected slot; when present it must be a formatted
|
||||||
|
// "… m²" string.
|
||||||
|
if (notify.size != null) {
|
||||||
expect(notify.size).toBeTypeOf('string');
|
expect(notify.size).toBeTypeOf('string');
|
||||||
expect(notify.size).toContain('m²');
|
expect(notify.size).toContain('m²');
|
||||||
|
}
|
||||||
expect(notify.title).toBeTypeOf('string');
|
expect(notify.title).toBeTypeOf('string');
|
||||||
expect(notify.link).toBeTypeOf('string');
|
expect(notify.link).toBeTypeOf('string');
|
||||||
expect(notify.address).toBeTypeOf('string');
|
expect(notify.address).toBeTypeOf('string');
|
||||||
/** check the values if possible **/
|
/** check the values if possible **/
|
||||||
expect(notify.size).toBeTypeOf('string');
|
|
||||||
expect(notify.title).not.toBe('');
|
expect(notify.title).not.toBe('');
|
||||||
expect(notify.address).not.toBe('');
|
expect(notify.address).not.toBe('');
|
||||||
});
|
});
|
||||||
|
|||||||
129
test/services/debug/debugBundleService.test.js
Normal file
129
test/services/debug/debugBundleService.test.js
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
describe('services/debug/debugBundleService.js', () => {
|
||||||
|
let svc;
|
||||||
|
let storedLogs;
|
||||||
|
let addedZipEntries;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
storedLogs = [];
|
||||||
|
addedZipEntries = [];
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Minimal AdmZip stand-in that records the in-memory entry names + payloads so we
|
||||||
|
* can assert what made it into the bundle without spinning up real zip parsing.
|
||||||
|
*/
|
||||||
|
class MockAdmZip {
|
||||||
|
constructor() {
|
||||||
|
this.entries = [];
|
||||||
|
}
|
||||||
|
addFile(name, buf) {
|
||||||
|
this.entries.push({ entryName: name, data: buf });
|
||||||
|
addedZipEntries.push({ entryName: name, content: buf.toString('utf-8') });
|
||||||
|
}
|
||||||
|
toBuffer() {
|
||||||
|
return Buffer.from(JSON.stringify(this.entries.map((e) => e.entryName)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
globalThis.__TEST_ADM_ZIP__ = MockAdmZip;
|
||||||
|
|
||||||
|
const ROOT = path.resolve('.');
|
||||||
|
const storagePath = path.join(ROOT, 'lib', 'services', 'debug', 'debugLogStorage.js');
|
||||||
|
const utilsPath = path.join(ROOT, 'lib', 'utils.js');
|
||||||
|
|
||||||
|
const storageMock = {
|
||||||
|
getAllDebugLogs: () => storedLogs,
|
||||||
|
};
|
||||||
|
const utilsMock = { getPackageVersion: async () => '22.5.0' };
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock(storagePath, () => storageMock);
|
||||||
|
vi.doMock(utilsPath, () => utilsMock);
|
||||||
|
|
||||||
|
svc = await import(path.join(ROOT, 'lib', 'services', 'debug', 'debugBundleService.js'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
delete globalThis.__TEST_ADM_ZIP__;
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('renderLogsTxt', () => {
|
||||||
|
it('returns an empty string when there are no rows', () => {
|
||||||
|
expect(svc.renderLogsTxt()).toBe('');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('formats each row as [date] LEVEL: message and keeps order', () => {
|
||||||
|
storedLogs.push({ id: 1, ts: 1717855200000, level: 'info', message: 'first line' });
|
||||||
|
storedLogs.push({ id: 2, ts: 1717855201000, level: 'warn', message: 'second line' });
|
||||||
|
|
||||||
|
const out = svc.renderLogsTxt();
|
||||||
|
|
||||||
|
expect(out).toMatch(/\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] INFO: first line/);
|
||||||
|
expect(out).toMatch(/\[\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}\] WARN: second line/);
|
||||||
|
expect(out.indexOf('first line')).toBeLessThan(out.indexOf('second line'));
|
||||||
|
expect(out.endsWith('\n')).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildSystemInfo', () => {
|
||||||
|
it('contains Fredy version, Node version and OS platform', async () => {
|
||||||
|
const sys = await svc.buildSystemInfo({ settings: null });
|
||||||
|
expect(sys).toMatch(/Fredy version:\s+22\.5\.0/);
|
||||||
|
expect(sys).toContain(`Node.js version: ${process.version}`);
|
||||||
|
expect(sys).toContain(`Platform: ${process.platform}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('redacts proxy URL credentials', async () => {
|
||||||
|
const sys = await svc.buildSystemInfo({
|
||||||
|
settings: { proxyUrl: 'http://secret:hunter2@proxy.example:8080', port: 9998 },
|
||||||
|
});
|
||||||
|
expect(sys).not.toContain('hunter2');
|
||||||
|
expect(sys).not.toContain('secret');
|
||||||
|
expect(sys).toContain('proxy.example');
|
||||||
|
expect(sys).toContain('port: 9998');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('strips session secrets from sanitized settings output', async () => {
|
||||||
|
const sys = await svc.buildSystemInfo({
|
||||||
|
settings: { session_secret: 'top-secret', sessionSecret: 'other-secret', port: 9998 },
|
||||||
|
});
|
||||||
|
expect(sys).not.toContain('top-secret');
|
||||||
|
expect(sys).not.toContain('other-secret');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildDebugBundleFileName', () => {
|
||||||
|
it('matches YYYY-MM-DD-FredyDebug-<version>.zip', async () => {
|
||||||
|
const name = await svc.buildDebugBundleFileName();
|
||||||
|
expect(name).toMatch(/^\d{4}-\d{2}-\d{2}-FredyDebug-22\.5\.0\.zip$/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('buildDebugBundleZip', () => {
|
||||||
|
it('always emits both logs.txt and sys.txt entries', async () => {
|
||||||
|
storedLogs.push({ id: 1, ts: 1717855200000, level: 'info', message: 'recorded line' });
|
||||||
|
await svc.buildDebugBundleZip({ settings: { port: 9998 } });
|
||||||
|
|
||||||
|
const names = addedZipEntries.map((e) => e.entryName).sort();
|
||||||
|
expect(names).toEqual(['logs.txt', 'sys.txt']);
|
||||||
|
|
||||||
|
const logs = addedZipEntries.find((e) => e.entryName === 'logs.txt');
|
||||||
|
const sys = addedZipEntries.find((e) => e.entryName === 'sys.txt');
|
||||||
|
expect(logs.content).toContain('recorded line');
|
||||||
|
expect(sys.content).toMatch(/Fredy version:\s+22\.5\.0/);
|
||||||
|
expect(sys.content).toContain('port: 9998');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes a placeholder message when no logs are stored', async () => {
|
||||||
|
await svc.buildDebugBundleZip({ settings: null });
|
||||||
|
const logs = addedZipEntries.find((e) => e.entryName === 'logs.txt');
|
||||||
|
expect(logs.content).toMatch(/no debug log entries/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
278
test/services/debug/debugLogStorage.test.js
Normal file
278
test/services/debug/debugLogStorage.test.js
Normal file
@@ -0,0 +1,278 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import path from 'node:path';
|
||||||
|
import Database from 'better-sqlite3';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire up an in-memory better-sqlite3 instance plus a stubbed settings module so the
|
||||||
|
* storage module under test can exercise real SQL while the rest of the dependency
|
||||||
|
* graph stays inert.
|
||||||
|
*/
|
||||||
|
async function bootstrap() {
|
||||||
|
const db = new Database(':memory:');
|
||||||
|
db.exec(`
|
||||||
|
CREATE TABLE debug_logs (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
ts INTEGER NOT NULL,
|
||||||
|
level TEXT NOT NULL,
|
||||||
|
message TEXT NOT NULL,
|
||||||
|
byte_size INTEGER NOT NULL
|
||||||
|
);
|
||||||
|
`);
|
||||||
|
|
||||||
|
const settings = { debug_logging_enabled: false, debug_logging_ever_enabled: false };
|
||||||
|
|
||||||
|
const ROOT = path.resolve('.');
|
||||||
|
const sqlitePath = path.join(ROOT, 'lib', 'services', 'storage', 'SqliteConnection.js');
|
||||||
|
const settingsPath = path.join(ROOT, 'lib', 'services', 'storage', 'settingsStorage.js');
|
||||||
|
|
||||||
|
const sqliteMock = {
|
||||||
|
default: {
|
||||||
|
getConnection: () => db,
|
||||||
|
execute: (sql, params = {}) => db.prepare(sql).run(params),
|
||||||
|
query: (sql, params = {}) => db.prepare(sql).all(params),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingsMock = {
|
||||||
|
getSettings: async () => ({ ...settings }),
|
||||||
|
upsertSettings: (entries) => {
|
||||||
|
const map = Array.isArray(entries) ? Object.fromEntries(entries) : entries;
|
||||||
|
for (const [k, v] of Object.entries(map)) {
|
||||||
|
settings[k] = v;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock(sqlitePath, () => sqliteMock);
|
||||||
|
vi.doMock(settingsPath, () => settingsMock);
|
||||||
|
|
||||||
|
const storage = await import(path.join(ROOT, 'lib', 'services', 'debug', 'debugLogStorage.js'));
|
||||||
|
storage._resetForTests();
|
||||||
|
return { storage, db, settings };
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('services/debug/debugLogStorage.js', () => {
|
||||||
|
let ctx;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
ctx = await bootstrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
try {
|
||||||
|
ctx.db.close();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('isEnabled is false before enableDebugLogging is called', async () => {
|
||||||
|
expect(ctx.storage.isEnabled()).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enableDebugLogging flips the cached flag and persists ever-enabled', async () => {
|
||||||
|
await ctx.storage.enableDebugLogging();
|
||||||
|
expect(ctx.storage.isEnabled()).toBe(true);
|
||||||
|
expect(ctx.settings.debug_logging_enabled).toBe(true);
|
||||||
|
expect(ctx.settings.debug_logging_ever_enabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('reloadEnabledFromSettings picks up persisted state after restart', async () => {
|
||||||
|
ctx.settings.debug_logging_enabled = true;
|
||||||
|
const enabled = await ctx.storage.reloadEnabledFromSettings();
|
||||||
|
expect(enabled).toBe(true);
|
||||||
|
expect(ctx.storage.isEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appendLogEntry writes only while enabled', async () => {
|
||||||
|
ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'before-enable' });
|
||||||
|
expect(ctx.db.prepare('SELECT COUNT(*) AS c FROM debug_logs').get().c).toBe(0);
|
||||||
|
|
||||||
|
await ctx.storage.enableDebugLogging();
|
||||||
|
ctx.storage.appendLogEntry({ ts: 2, level: 'warn', message: 'after-enable' });
|
||||||
|
expect(ctx.db.prepare('SELECT COUNT(*) AS c FROM debug_logs').get().c).toBe(1);
|
||||||
|
|
||||||
|
const row = ctx.db.prepare('SELECT level, message, byte_size FROM debug_logs').get();
|
||||||
|
expect(row.level).toBe('warn');
|
||||||
|
expect(row.message).toBe('after-enable');
|
||||||
|
expect(row.byte_size).toBe(Buffer.byteLength('after-enable', 'utf-8'));
|
||||||
|
});
|
||||||
|
|
||||||
|
it('disableDebugLogging stops writes but keeps existing rows', async () => {
|
||||||
|
await ctx.storage.enableDebugLogging();
|
||||||
|
ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'keep-me' });
|
||||||
|
await ctx.storage.disableDebugLogging();
|
||||||
|
ctx.storage.appendLogEntry({ ts: 2, level: 'info', message: 'never-written' });
|
||||||
|
|
||||||
|
const rows = ctx.db.prepare('SELECT message FROM debug_logs ORDER BY id').all();
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].message).toBe('keep-me');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enableDebugLogging clears previous logs only when asked', async () => {
|
||||||
|
await ctx.storage.enableDebugLogging();
|
||||||
|
ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'pre-existing' });
|
||||||
|
await ctx.storage.disableDebugLogging();
|
||||||
|
|
||||||
|
await ctx.storage.enableDebugLogging({ clearPrevious: false });
|
||||||
|
expect(ctx.db.prepare('SELECT COUNT(*) AS c FROM debug_logs').get().c).toBe(1);
|
||||||
|
|
||||||
|
await ctx.storage.disableDebugLogging();
|
||||||
|
await ctx.storage.enableDebugLogging({ clearPrevious: true });
|
||||||
|
expect(ctx.db.prepare('SELECT COUNT(*) AS c FROM debug_logs').get().c).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('hasAnyLogs / wasEverEnabled report correctly', async () => {
|
||||||
|
expect(ctx.storage.hasAnyLogs()).toBe(false);
|
||||||
|
expect(await ctx.storage.wasEverEnabled()).toBe(false);
|
||||||
|
|
||||||
|
await ctx.storage.enableDebugLogging();
|
||||||
|
ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'hi' });
|
||||||
|
expect(ctx.storage.hasAnyLogs()).toBe(true);
|
||||||
|
expect(await ctx.storage.wasEverEnabled()).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getCurrentSize reflects the on-disk byte total', async () => {
|
||||||
|
await ctx.storage.enableDebugLogging();
|
||||||
|
ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'hello' }); // 5 bytes
|
||||||
|
ctx.storage.appendLogEntry({ ts: 2, level: 'info', message: 'world!' }); // 6 bytes
|
||||||
|
expect(await ctx.storage.getCurrentSize()).toBe(11);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolling buffer drops oldest rows once the cap is exceeded', async () => {
|
||||||
|
await ctx.storage.enableDebugLogging();
|
||||||
|
const cap = ctx.storage.getMaxSize();
|
||||||
|
|
||||||
|
// Insert one row whose payload exceeds the entire cap. trimToFit must drop the
|
||||||
|
// oldest row(s) until the live size falls back under the cap. With a single
|
||||||
|
// oversized row, the only outcome is "table empty".
|
||||||
|
const giantText = 'X'.repeat(cap + 1024);
|
||||||
|
ctx.storage.appendLogEntry({ ts: 10, level: 'info', message: giantText });
|
||||||
|
|
||||||
|
const remaining = await ctx.storage.getCurrentSize();
|
||||||
|
expect(remaining).toBeLessThanOrEqual(cap);
|
||||||
|
expect(remaining).toBe(0);
|
||||||
|
expect(ctx.db.prepare('SELECT COUNT(*) AS c FROM debug_logs').get().c).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rolling buffer keeps newer rows when only the oldest need to go', async () => {
|
||||||
|
await ctx.storage.enableDebugLogging();
|
||||||
|
const cap = ctx.storage.getMaxSize();
|
||||||
|
|
||||||
|
// Push the size just over the cap with one big row, then a smaller "newer" row
|
||||||
|
// that should survive the trim because it is not at the head of the queue.
|
||||||
|
const bigText = 'A'.repeat(cap - 10); // ~5 MiB - 10 bytes
|
||||||
|
ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: bigText });
|
||||||
|
|
||||||
|
// At this point we are just under the cap. Pushing one more row will tip us over.
|
||||||
|
ctx.storage.appendLogEntry({ ts: 2, level: 'warn', message: 'tip-over message which keeps us above cap' });
|
||||||
|
|
||||||
|
const remainingRows = ctx.db.prepare('SELECT message FROM debug_logs ORDER BY id ASC').all();
|
||||||
|
// The oldest (big) row must be gone; the newer one survives.
|
||||||
|
expect(remainingRows).toHaveLength(1);
|
||||||
|
expect(remainingRows[0].message).toContain('tip-over');
|
||||||
|
|
||||||
|
const remainingSize = await ctx.storage.getCurrentSize();
|
||||||
|
expect(remainingSize).toBeLessThanOrEqual(cap);
|
||||||
|
// And the cache must match what SQLite reports, verifies no drift after trim.
|
||||||
|
const dbSize = ctx.db.prepare('SELECT COALESCE(SUM(byte_size),0) AS s FROM debug_logs').get().s;
|
||||||
|
expect(remainingSize).toBe(dbSize);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('cachedSize stays consistent across enable → append → disable → re-enable(clear) cycles', async () => {
|
||||||
|
await ctx.storage.enableDebugLogging();
|
||||||
|
ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'one' });
|
||||||
|
ctx.storage.appendLogEntry({ ts: 2, level: 'info', message: 'two' });
|
||||||
|
const sizeAfterFirst = await ctx.storage.getCurrentSize();
|
||||||
|
|
||||||
|
await ctx.storage.disableDebugLogging();
|
||||||
|
expect(await ctx.storage.getCurrentSize()).toBe(sizeAfterFirst);
|
||||||
|
|
||||||
|
await ctx.storage.enableDebugLogging({ clearPrevious: true });
|
||||||
|
expect(await ctx.storage.getCurrentSize()).toBe(0);
|
||||||
|
|
||||||
|
ctx.storage.appendLogEntry({ ts: 3, level: 'info', message: 'fresh' });
|
||||||
|
const dbSize = ctx.db.prepare('SELECT COALESCE(SUM(byte_size),0) AS s FROM debug_logs').get().s;
|
||||||
|
expect(await ctx.storage.getCurrentSize()).toBe(dbSize);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clearAllDebugLogs empties the table and resets cached size', async () => {
|
||||||
|
await ctx.storage.enableDebugLogging();
|
||||||
|
ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'foo' });
|
||||||
|
ctx.storage.appendLogEntry({ ts: 2, level: 'info', message: 'bar' });
|
||||||
|
expect(await ctx.storage.getCurrentSize()).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
ctx.storage.clearAllDebugLogs();
|
||||||
|
|
||||||
|
expect(ctx.db.prepare('SELECT COUNT(*) AS c FROM debug_logs').get().c).toBe(0);
|
||||||
|
expect(await ctx.storage.getCurrentSize()).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('getAllDebugLogs returns rows ordered chronologically', async () => {
|
||||||
|
await ctx.storage.enableDebugLogging();
|
||||||
|
ctx.storage.appendLogEntry({ ts: 1, level: 'info', message: 'first' });
|
||||||
|
ctx.storage.appendLogEntry({ ts: 2, level: 'warn', message: 'second' });
|
||||||
|
ctx.storage.appendLogEntry({ ts: 3, level: 'error', message: 'third' });
|
||||||
|
|
||||||
|
const rows = ctx.storage.getAllDebugLogs();
|
||||||
|
expect(rows.map((r) => r.message)).toEqual(['first', 'second', 'third']);
|
||||||
|
expect(rows.map((r) => r.level)).toEqual(['info', 'warn', 'error']);
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('logger sink wiring', () => {
|
||||||
|
let logger;
|
||||||
|
let consoleSpies;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
// Storage imports the same logger module; vi.resetModules() ensured both share
|
||||||
|
// the same fresh instance for this test. Spies silence console output so the
|
||||||
|
// vitest report stays clean while we exercise real logger.info() calls.
|
||||||
|
logger = (await import(path.resolve('lib/services/logger.js'))).default;
|
||||||
|
consoleSpies = {
|
||||||
|
debug: vi.spyOn(console, 'debug').mockImplementation(() => {}),
|
||||||
|
info: vi.spyOn(console, 'info').mockImplementation(() => {}),
|
||||||
|
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
|
||||||
|
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
// Detach sink between tests to prevent cross-test pollution from the shared
|
||||||
|
// logger module instance.
|
||||||
|
logger.setDebugLogSink(null);
|
||||||
|
for (const spy of Object.values(consoleSpies)) spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('routes logger calls into debug_logs once enabled', async () => {
|
||||||
|
await ctx.storage.enableDebugLogging();
|
||||||
|
logger.info('captured-via-logger');
|
||||||
|
const rows = ctx.db.prepare('SELECT level, message FROM debug_logs').all();
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].level).toBe('info');
|
||||||
|
expect(rows[0].message).toContain('captured-via-logger');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('detaches the sink on disable so logger calls no longer hit the DB', async () => {
|
||||||
|
await ctx.storage.enableDebugLogging();
|
||||||
|
await ctx.storage.disableDebugLogging();
|
||||||
|
logger.info('not-captured');
|
||||||
|
expect(ctx.db.prepare('SELECT COUNT(*) AS c FROM debug_logs').get().c).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('restores the sink on reloadEnabledFromSettings when persisted state is on', async () => {
|
||||||
|
ctx.settings.debug_logging_enabled = true;
|
||||||
|
await ctx.storage.reloadEnabledFromSettings();
|
||||||
|
logger.warn('captured-after-restart');
|
||||||
|
const rows = ctx.db.prepare('SELECT level, message FROM debug_logs').all();
|
||||||
|
expect(rows).toHaveLength(1);
|
||||||
|
expect(rows[0].level).toBe('warn');
|
||||||
|
expect(rows[0].message).toContain('captured-after-restart');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
250
test/services/debug/debugRouter.test.js
Normal file
250
test/services/debug/debugRouter.test.js
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import path from 'node:path';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
|
||||||
|
describe('api/routes/debugRouter.js', () => {
|
||||||
|
let app;
|
||||||
|
let state;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
state = {
|
||||||
|
enabled: false,
|
||||||
|
hasLogs: false,
|
||||||
|
everEnabled: false,
|
||||||
|
size: 0,
|
||||||
|
max: 5 * 1024 * 1024,
|
||||||
|
};
|
||||||
|
|
||||||
|
const ROOT = path.resolve('.');
|
||||||
|
const storagePath = path.join(ROOT, 'lib', 'services', 'debug', 'debugLogStorage.js');
|
||||||
|
const bundlePath = path.join(ROOT, 'lib', 'services', 'debug', 'debugBundleService.js');
|
||||||
|
const settingsStoragePath = path.join(ROOT, 'lib', 'services', 'storage', 'settingsStorage.js');
|
||||||
|
|
||||||
|
const storageMock = {
|
||||||
|
isEnabled: () => state.enabled,
|
||||||
|
enableDebugLogging: async ({ clearPrevious = false } = {}) => {
|
||||||
|
state.enabled = true;
|
||||||
|
state.everEnabled = true;
|
||||||
|
if (clearPrevious) {
|
||||||
|
state.hasLogs = false;
|
||||||
|
state.size = 0;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
disableDebugLogging: async () => {
|
||||||
|
state.enabled = false;
|
||||||
|
},
|
||||||
|
getCurrentSize: async () => state.size,
|
||||||
|
getMaxSize: () => state.max,
|
||||||
|
hasAnyLogs: () => state.hasLogs,
|
||||||
|
wasEverEnabled: async () => state.everEnabled,
|
||||||
|
clearAllDebugLogs: () => {
|
||||||
|
state.hasLogs = false;
|
||||||
|
state.size = 0;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const bundleMock = {
|
||||||
|
buildDebugBundleFileName: async () => '2026-06-08-FredyDebug-22.5.0.zip',
|
||||||
|
buildDebugBundleZip: async () => Buffer.from('FAKEZIP'),
|
||||||
|
};
|
||||||
|
|
||||||
|
const settingsMock = {
|
||||||
|
getSettings: async () => ({ port: 9998 }),
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock(storagePath, () => storageMock);
|
||||||
|
vi.doMock(bundlePath, () => bundleMock);
|
||||||
|
vi.doMock(settingsStoragePath, () => settingsMock);
|
||||||
|
|
||||||
|
const mod = await import(path.join(ROOT, 'lib', 'api', 'routes', 'debugRouter.js'));
|
||||||
|
const plugin = mod.default;
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(plugin, { prefix: '/api/admin/debug' });
|
||||||
|
await app.register(
|
||||||
|
async (sub) => {
|
||||||
|
mod.registerDebugPublicProbe(sub);
|
||||||
|
},
|
||||||
|
{ prefix: '/api/debug' },
|
||||||
|
);
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /status returns the current snapshot', async () => {
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/admin/debug/status' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toEqual({
|
||||||
|
enabled: false,
|
||||||
|
size: 0,
|
||||||
|
max: state.max,
|
||||||
|
hasLogs: false,
|
||||||
|
everEnabled: false,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /enable flips the feature on and returns updated status', async () => {
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/admin/debug/enable',
|
||||||
|
payload: {},
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const json = res.json();
|
||||||
|
expect(json.enabled).toBe(true);
|
||||||
|
expect(json.everEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /enable with clearPrevious=true wipes existing logs first', async () => {
|
||||||
|
state.hasLogs = true;
|
||||||
|
state.size = 1234;
|
||||||
|
state.everEnabled = true;
|
||||||
|
|
||||||
|
const res = await app.inject({
|
||||||
|
method: 'POST',
|
||||||
|
url: '/api/admin/debug/enable',
|
||||||
|
payload: { clearPrevious: true },
|
||||||
|
});
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const json = res.json();
|
||||||
|
expect(json.enabled).toBe(true);
|
||||||
|
expect(json.hasLogs).toBe(false);
|
||||||
|
expect(json.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('POST /disable turns the feature off without losing existing logs', async () => {
|
||||||
|
state.enabled = true;
|
||||||
|
state.hasLogs = true;
|
||||||
|
state.everEnabled = true;
|
||||||
|
state.size = 99;
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'POST', url: '/api/admin/debug/disable' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const json = res.json();
|
||||||
|
expect(json.enabled).toBe(false);
|
||||||
|
expect(json.hasLogs).toBe(true);
|
||||||
|
expect(json.size).toBe(99);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /download returns 409 when the feature was never enabled', async () => {
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/admin/debug/download' });
|
||||||
|
expect(res.statusCode).toBe(409);
|
||||||
|
expect(res.json().error).toMatch(/never produced any data/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /download returns 409 when ever-enabled but no logs are stored', async () => {
|
||||||
|
state.everEnabled = true;
|
||||||
|
state.hasLogs = false;
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/admin/debug/download' });
|
||||||
|
expect(res.statusCode).toBe(409);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /download streams a zip with the expected headers when logs exist', async () => {
|
||||||
|
state.everEnabled = true;
|
||||||
|
state.hasLogs = true;
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/admin/debug/download' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.headers['content-type']).toBe('application/zip');
|
||||||
|
expect(res.headers['content-disposition']).toContain('FredyDebug');
|
||||||
|
expect(res.rawPayload.toString('utf-8')).toBe('FAKEZIP');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('DELETE /logs wipes stored logs without touching the enabled flag', async () => {
|
||||||
|
state.enabled = true;
|
||||||
|
state.hasLogs = true;
|
||||||
|
state.everEnabled = true;
|
||||||
|
state.size = 1234;
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'DELETE', url: '/api/admin/debug/logs' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const json = res.json();
|
||||||
|
expect(json.enabled).toBe(true);
|
||||||
|
expect(json.hasLogs).toBe(false);
|
||||||
|
expect(json.size).toBe(0);
|
||||||
|
// everEnabled must stay true so the download button does not change semantics.
|
||||||
|
expect(json.everEnabled).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('GET /api/debug/active returns only the enabled boolean (no other settings)', async () => {
|
||||||
|
state.enabled = false;
|
||||||
|
let res = await app.inject({ method: 'GET', url: '/api/debug/active' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toEqual({ enabled: false });
|
||||||
|
|
||||||
|
state.enabled = true;
|
||||||
|
res = await app.inject({ method: 'GET', url: '/api/debug/active' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
expect(res.json()).toEqual({ enabled: true });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('api/routes/debugRouter.js - admin-only enforcement', () => {
|
||||||
|
let app;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
const ROOT = path.resolve('.');
|
||||||
|
const storagePath = path.join(ROOT, 'lib', 'services', 'debug', 'debugLogStorage.js');
|
||||||
|
const bundlePath = path.join(ROOT, 'lib', 'services', 'debug', 'debugBundleService.js');
|
||||||
|
const settingsStoragePath = path.join(ROOT, 'lib', 'services', 'storage', 'settingsStorage.js');
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock(storagePath, () => ({
|
||||||
|
isEnabled: () => false,
|
||||||
|
enableDebugLogging: async () => {},
|
||||||
|
disableDebugLogging: async () => {},
|
||||||
|
getCurrentSize: async () => 0,
|
||||||
|
getMaxSize: () => 5 * 1024 * 1024,
|
||||||
|
hasAnyLogs: () => false,
|
||||||
|
wasEverEnabled: async () => false,
|
||||||
|
clearAllDebugLogs: () => {},
|
||||||
|
}));
|
||||||
|
vi.doMock(bundlePath, () => ({
|
||||||
|
buildDebugBundleFileName: async () => 'x.zip',
|
||||||
|
buildDebugBundleZip: async () => Buffer.from(''),
|
||||||
|
}));
|
||||||
|
vi.doMock(settingsStoragePath, () => ({
|
||||||
|
getSettings: async () => ({}),
|
||||||
|
}));
|
||||||
|
|
||||||
|
const plugin = (await import(path.join(ROOT, 'lib', 'api', 'routes', 'debugRouter.js'))).default;
|
||||||
|
app = Fastify({ logger: false });
|
||||||
|
await app.register(
|
||||||
|
async (sub) => {
|
||||||
|
// Same wiring shape as lib/api/api.js: apply adminHook before the plugin.
|
||||||
|
sub.addHook('preHandler', async (request, reply) => {
|
||||||
|
reply.code(401).send();
|
||||||
|
});
|
||||||
|
sub.register(plugin, { prefix: '/api/admin/debug' });
|
||||||
|
},
|
||||||
|
{ prefix: '/' },
|
||||||
|
);
|
||||||
|
await app.ready();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects non-admin callers with 401 on every endpoint', async () => {
|
||||||
|
for (const route of [
|
||||||
|
['GET', '/api/admin/debug/status'],
|
||||||
|
['POST', '/api/admin/debug/enable'],
|
||||||
|
['POST', '/api/admin/debug/disable'],
|
||||||
|
['GET', '/api/admin/debug/download'],
|
||||||
|
['DELETE', '/api/admin/debug/logs'],
|
||||||
|
]) {
|
||||||
|
const [method, url] = route;
|
||||||
|
const res = await app.inject({ method, url, payload: method === 'POST' ? {} : undefined });
|
||||||
|
expect(res.statusCode, `${method} ${url}`).toBe(401);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -31,12 +31,35 @@ describe('#immoscout-mobile URL conversion', () => {
|
|||||||
const webUrl =
|
const webUrl =
|
||||||
'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?heatingtypes=central,selfcontainedcentral&haspromotion=false&numberofrooms=2.0-5.0&livingspace=10.0-25.0&energyefficiencyclasses=a,b,c,d,e,f,g,h,a_plus&exclusioncriteria=projectlisting,swapflat&equipment=parking,cellar,builtinkitchen,lift,garden,guesttoilet,balcony&petsallowedtypes=no,yes,negotiable&price=10.0-100.0&constructionyear=1920-2026&apartmenttypes=halfbasement,penthouse,other,loft,groundfloor,terracedflat,raisedgroundfloor,roofstorey,apartment,maisonette&pricetype=calculatedtotalrent&floor=2-7&enteredFrom=result_list';
|
'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?heatingtypes=central,selfcontainedcentral&haspromotion=false&numberofrooms=2.0-5.0&livingspace=10.0-25.0&energyefficiencyclasses=a,b,c,d,e,f,g,h,a_plus&exclusioncriteria=projectlisting,swapflat&equipment=parking,cellar,builtinkitchen,lift,garden,guesttoilet,balcony&petsallowedtypes=no,yes,negotiable&price=10.0-100.0&constructionyear=1920-2026&apartmenttypes=halfbasement,penthouse,other,loft,groundfloor,terracedflat,raisedgroundfloor,roofstorey,apartment,maisonette&pricetype=calculatedtotalrent&floor=2-7&enteredFrom=result_list';
|
||||||
const expectedMobileUrl =
|
const expectedMobileUrl =
|
||||||
'https://api.mobile.immobilienscout24.de/search/list?apartmenttypes=halfbasement,penthouse,other,loft,groundfloor,terracedflat,raisedgroundfloor,roofstorey,apartment,maisonette&constructionyear=1920-2026&energyefficiencyclasses=a,b,c,d,e,f,g,h,a_plus&equipment=parking,cellar,builtInKitchen,lift,garden,guestToilet,balcony&exclusioncriteria=projectlisting,swapflat&floor=2-7&geocodes=%2Fde%2Fberlin%2Fberlin&haspromotion=false&heatingtypes=central,selfcontainedcentral&livingspace=10.0-25.0&numberofrooms=2.0-5.0&petsallowedtypes=no,yes,negotiable&price=10.0-100.0&pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region';
|
'https://api.mobile.immobilienscout24.de/search/list?apartmenttypes=halfbasement,penthouse,other,loft,groundfloor,terracedflat,raisedgroundfloor,roofstorey,apartment,maisonette&constructionyear=1920-2026&energyefficiencyclasses=a,b,c,d,e,f,g,h,a_plus&equipment=parking,cellar,builtInKitchen,lift,garden,guestToilet,balcony&exclusioncriteria=projectlisting,swap_flat&floor=2-7&geocodes=%2Fde%2Fberlin%2Fberlin&haspromotion=false&heatingtypes=central,selfcontainedcentral&livingspace=10.0-25.0&numberofrooms=2.0-5.0&petsallowedtypes=no,yes,negotiable&price=10.0-100.0&pricetype=calculatedtotalrent&realestatetype=apartmentrent&searchType=region';
|
||||||
|
|
||||||
const actualMobileUrl = convertWebToMobile(webUrl);
|
const actualMobileUrl = convertWebToMobile(webUrl);
|
||||||
expect(actualMobileUrl).toBe(expectedMobileUrl);
|
expect(actualMobileUrl).toBe(expectedMobileUrl);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// The web UI encodes "no swap flats" as exclusioncriteria=swapflat, but the
|
||||||
|
// mobile API only understands swap_flat. Unknown values are not ignored by the
|
||||||
|
// API - the search silently returns 0 results, so the mapping is essential.
|
||||||
|
it('should map exclusioncriteria=swapflat to the mobile API value swap_flat', () => {
|
||||||
|
const webUrl =
|
||||||
|
'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?exclusioncriteria=swapflat&price=-1500.0';
|
||||||
|
|
||||||
|
const converted = convertWebToMobile(webUrl);
|
||||||
|
const queryParams = new URL(converted).searchParams;
|
||||||
|
expect(queryParams.get('exclusioncriteria')).toBe('swap_flat');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Values the mobile API shares with the web API (e.g. projectlisting) must
|
||||||
|
// pass through unchanged, in any combination with mapped values.
|
||||||
|
it('should keep other exclusioncriteria values untouched', () => {
|
||||||
|
const webUrl =
|
||||||
|
'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mieten?exclusioncriteria=projectlisting,swapflat';
|
||||||
|
|
||||||
|
const converted = convertWebToMobile(webUrl);
|
||||||
|
const queryParams = new URL(converted).searchParams;
|
||||||
|
expect(queryParams.get('exclusioncriteria')).toBe('projectlisting,swap_flat');
|
||||||
|
});
|
||||||
|
|
||||||
// Test URL conversion of web-only SEO path
|
// Test URL conversion of web-only SEO path
|
||||||
it('should convert a SEO web path to the correct query params', () => {
|
it('should convert a SEO web path to the correct query params', () => {
|
||||||
const webUrl = 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mit-balkon-mieten?equipment=garden';
|
const webUrl = 'https://www.immobilienscout24.de/Suche/de/berlin/berlin/wohnung-mit-balkon-mieten?equipment=garden';
|
||||||
|
|||||||
110
test/services/jobs/dashboardRouter.test.js
Normal file
110
test/services/jobs/dashboardRouter.test.js
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import path from 'node:path';
|
||||||
|
import Fastify from 'fastify';
|
||||||
|
|
||||||
|
describe('api/routes/dashboardRouter.js', () => {
|
||||||
|
let app;
|
||||||
|
let state;
|
||||||
|
|
||||||
|
async function buildApp() {
|
||||||
|
const ROOT = path.resolve('.');
|
||||||
|
const jobStoragePath = path.join(ROOT, 'lib', 'services', 'storage', 'jobStorage.js');
|
||||||
|
const listingsStoragePath = path.join(ROOT, 'lib', 'services', 'storage', 'listingsStorage.js');
|
||||||
|
const settingsStoragePath = path.join(ROOT, 'lib', 'services', 'storage', 'settingsStorage.js');
|
||||||
|
const securityPath = path.join(ROOT, 'lib', 'api', 'security.js');
|
||||||
|
|
||||||
|
vi.resetModules();
|
||||||
|
vi.doMock(jobStoragePath, () => ({
|
||||||
|
getJobs: () => state.jobs.slice(),
|
||||||
|
}));
|
||||||
|
vi.doMock(listingsStoragePath, () => ({
|
||||||
|
getListingsKpisForJobIds: () => ({ numberOfActiveListings: 0, medianPriceOfListings: 0 }),
|
||||||
|
getProviderDistributionForJobIds: () => [],
|
||||||
|
}));
|
||||||
|
vi.doMock(settingsStoragePath, () => ({
|
||||||
|
getSettings: async () => ({ interval: 30 }),
|
||||||
|
}));
|
||||||
|
vi.doMock(securityPath, () => ({
|
||||||
|
isAdmin: () => state.admin,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mod = await import(path.join(ROOT, 'lib', 'api', 'routes', 'dashboardRouter.js'));
|
||||||
|
const plugin = mod.default;
|
||||||
|
const instance = Fastify({ logger: false });
|
||||||
|
instance.addHook('onRequest', async (request) => {
|
||||||
|
request.session = { currentUser: state.currentUser, createdAt: Date.now() };
|
||||||
|
});
|
||||||
|
await instance.register(plugin, { prefix: '/api/dashboard' });
|
||||||
|
await instance.ready();
|
||||||
|
return instance;
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
state = {
|
||||||
|
currentUser: 'u1',
|
||||||
|
admin: false,
|
||||||
|
jobs: [],
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(async () => {
|
||||||
|
if (app) await app.close();
|
||||||
|
app = null;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives lastRun from the most recent accessible job for a regular user', async () => {
|
||||||
|
state.jobs = [
|
||||||
|
{ id: 'a', userId: 'u1', shared_with_user: [], lastRunAt: 1000 },
|
||||||
|
{ id: 'b', userId: 'u1', shared_with_user: [], lastRunAt: 5000 },
|
||||||
|
{ id: 'c', userId: 'someone-else', shared_with_user: [], lastRunAt: 9999 },
|
||||||
|
];
|
||||||
|
app = await buildApp();
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/dashboard/' });
|
||||||
|
expect(res.statusCode).toBe(200);
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.general.lastRun).toBe(5000);
|
||||||
|
expect(body.general.nextRun).toBe(5000 + 30 * 60000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes shared jobs in the lastRun calculation', async () => {
|
||||||
|
state.jobs = [
|
||||||
|
{ id: 'mine', userId: 'u1', shared_with_user: [], lastRunAt: 1000 },
|
||||||
|
{ id: 'shared', userId: 'someone-else', shared_with_user: ['u1'], lastRunAt: 4000 },
|
||||||
|
];
|
||||||
|
app = await buildApp();
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/dashboard/' });
|
||||||
|
expect(res.json().general.lastRun).toBe(4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('admins see lastRun across all jobs', async () => {
|
||||||
|
state.admin = true;
|
||||||
|
state.jobs = [
|
||||||
|
{ id: 'a', userId: 'someone', shared_with_user: [], lastRunAt: 1000 },
|
||||||
|
{ id: 'b', userId: 'another', shared_with_user: [], lastRunAt: 7000 },
|
||||||
|
];
|
||||||
|
app = await buildApp();
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/dashboard/' });
|
||||||
|
expect(res.json().general.lastRun).toBe(7000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns null lastRun and 0 nextRun when no accessible job has ever run', async () => {
|
||||||
|
state.jobs = [
|
||||||
|
{ id: 'a', userId: 'u1', shared_with_user: [], lastRunAt: null },
|
||||||
|
{ id: 'b', userId: 'someone-else', shared_with_user: [], lastRunAt: 9999 },
|
||||||
|
];
|
||||||
|
app = await buildApp();
|
||||||
|
|
||||||
|
const res = await app.inject({ method: 'GET', url: '/api/dashboard/' });
|
||||||
|
const body = res.json();
|
||||||
|
expect(body.general.lastRun).toBeNull();
|
||||||
|
expect(body.general.nextRun).toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -29,6 +29,7 @@ describe('services/jobs/jobExecutionService', () => {
|
|||||||
vi.doMock(jobStoragePath, () => ({
|
vi.doMock(jobStoragePath, () => ({
|
||||||
getJob: (id) => state.jobsById[id] || null,
|
getJob: (id) => state.jobsById[id] || null,
|
||||||
getJobs: () => state.jobsList.slice(),
|
getJobs: () => state.jobsList.slice(),
|
||||||
|
updateJobLastRunAt: (id, timestamp) => calls.lastRunUpdates.push({ id, timestamp }),
|
||||||
}));
|
}));
|
||||||
vi.doMock(userStoragePath, () => ({
|
vi.doMock(userStoragePath, () => ({
|
||||||
getUsers: () => state.users.slice(),
|
getUsers: () => state.users.slice(),
|
||||||
@@ -65,7 +66,7 @@ describe('services/jobs/jobExecutionService', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
bus = new EventEmitter();
|
bus = new EventEmitter();
|
||||||
calls = { sent: [], markRunning: [] };
|
calls = { sent: [], markRunning: [], lastRunUpdates: [] };
|
||||||
state = {
|
state = {
|
||||||
jobsById: {},
|
jobsById: {},
|
||||||
jobsList: [],
|
jobsList: [],
|
||||||
@@ -119,4 +120,23 @@ describe('services/jobs/jobExecutionService', () => {
|
|||||||
await new Promise((r) => setTimeout(r, 0));
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
expect(new Set(calls.markRunning)).toEqual(new Set(['j1', 'j2']));
|
expect(new Set(calls.markRunning)).toEqual(new Set(['j1', 'j2']));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('persists last_run_at when a job is executed', async () => {
|
||||||
|
state.jobsById['j1'] = { id: 'j1', enabled: true, userId: 'u1', provider: [] };
|
||||||
|
state.jobsList = [state.jobsById['j1']];
|
||||||
|
state.users = [{ id: 'u1', isAdmin: false }];
|
||||||
|
|
||||||
|
await initService();
|
||||||
|
|
||||||
|
const before = Date.now();
|
||||||
|
bus.emit('jobs:runOne', { jobId: 'j1' });
|
||||||
|
await new Promise((r) => setTimeout(r, 0));
|
||||||
|
const after = Date.now();
|
||||||
|
|
||||||
|
expect(calls.lastRunUpdates.length).toBe(1);
|
||||||
|
const [update] = calls.lastRunUpdates;
|
||||||
|
expect(update.id).toBe('j1');
|
||||||
|
expect(update.timestamp).toBeGreaterThanOrEqual(before);
|
||||||
|
expect(update.timestamp).toBeLessThanOrEqual(after);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
89
test/services/logger.test.js
Normal file
89
test/services/logger.test.js
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||||
|
import path from 'node:path';
|
||||||
|
|
||||||
|
describe('services/logger.js - debug log sink', () => {
|
||||||
|
let logger;
|
||||||
|
let setDebugLogSink;
|
||||||
|
let consoleSpies;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
vi.resetModules();
|
||||||
|
const mod = await import(path.resolve('lib/services/logger.js'));
|
||||||
|
logger = mod.default;
|
||||||
|
setDebugLogSink = mod.setDebugLogSink;
|
||||||
|
|
||||||
|
// Silence console output so test runner stdout stays readable while still
|
||||||
|
// letting us inspect what the logger emitted if a test wants to.
|
||||||
|
consoleSpies = {
|
||||||
|
debug: vi.spyOn(console, 'debug').mockImplementation(() => {}),
|
||||||
|
info: vi.spyOn(console, 'info').mockImplementation(() => {}),
|
||||||
|
warn: vi.spyOn(console, 'warn').mockImplementation(() => {}),
|
||||||
|
error: vi.spyOn(console, 'error').mockImplementation(() => {}),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
setDebugLogSink(null);
|
||||||
|
for (const spy of Object.values(consoleSpies)) spy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op for the sink when none is registered', () => {
|
||||||
|
// Just make sure nothing throws.
|
||||||
|
expect(() => logger.info('hello')).not.toThrow();
|
||||||
|
expect(() => logger.error(new Error('boom'))).not.toThrow();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('forwards every log level (including debug) to the registered sink', () => {
|
||||||
|
const captured = [];
|
||||||
|
setDebugLogSink((entry) => captured.push(entry));
|
||||||
|
|
||||||
|
logger.debug('debug-line');
|
||||||
|
logger.info('info-line');
|
||||||
|
logger.warn('warn-line');
|
||||||
|
logger.error('error-line');
|
||||||
|
|
||||||
|
expect(captured).toHaveLength(4);
|
||||||
|
expect(captured.map((c) => c.level)).toEqual(['debug', 'info', 'warn', 'error']);
|
||||||
|
expect(captured[0].message).toContain('debug-line');
|
||||||
|
expect(captured[1].message).toContain('info-line');
|
||||||
|
expect(captured[2].message).toContain('warn-line');
|
||||||
|
expect(captured[3].message).toContain('error-line');
|
||||||
|
for (const c of captured) {
|
||||||
|
expect(typeof c.ts).toBe('number');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes Error stacks for the sink instead of "[object Object]"', () => {
|
||||||
|
const captured = [];
|
||||||
|
setDebugLogSink((entry) => captured.push(entry));
|
||||||
|
|
||||||
|
logger.error(new Error('boom'));
|
||||||
|
|
||||||
|
expect(captured).toHaveLength(1);
|
||||||
|
expect(captured[0].message).toContain('Error: boom');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops forwarding once the sink is unregistered', () => {
|
||||||
|
const captured = [];
|
||||||
|
setDebugLogSink((entry) => captured.push(entry));
|
||||||
|
logger.info('one');
|
||||||
|
setDebugLogSink(null);
|
||||||
|
logger.info('two');
|
||||||
|
|
||||||
|
expect(captured).toHaveLength(1);
|
||||||
|
expect(captured[0].message).toContain('one');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not break the caller when the sink throws', () => {
|
||||||
|
setDebugLogSink(() => {
|
||||||
|
throw new Error('sink exploded');
|
||||||
|
});
|
||||||
|
expect(() => logger.info('still works')).not.toThrow();
|
||||||
|
expect(consoleSpies.info).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
64
test/storage/jobStorage.test.js
Normal file
64
test/storage/jobStorage.test.js
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/*
|
||||||
|
* 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 SqliteConnection so we can assert which SQL the storage layer runs
|
||||||
|
// without spinning up a real SQLite DB.
|
||||||
|
|
||||||
|
const calls = {
|
||||||
|
execute: [],
|
||||||
|
query: [],
|
||||||
|
};
|
||||||
|
|
||||||
|
const sqliteMock = {
|
||||||
|
execute: (sql, params) => {
|
||||||
|
calls.execute.push({ sql, params });
|
||||||
|
return { changes: 1 };
|
||||||
|
},
|
||||||
|
query: (sql, params) => {
|
||||||
|
calls.query.push({ sql, params });
|
||||||
|
if (sqliteMock.__queryHandler) return sqliteMock.__queryHandler(sql, params);
|
||||||
|
return [];
|
||||||
|
},
|
||||||
|
__queryHandler: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
vi.mock('../../lib/services/storage/SqliteConnection.js', () => ({
|
||||||
|
default: sqliteMock,
|
||||||
|
}));
|
||||||
|
|
||||||
|
describe('jobStorage.getJobs', () => {
|
||||||
|
let jobStorage;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
calls.execute.length = 0;
|
||||||
|
calls.query.length = 0;
|
||||||
|
sqliteMock.__queryHandler = null;
|
||||||
|
jobStorage = await import('../../lib/services/storage/jobStorage.js');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters out disabled jobs by default (WHERE j.enabled = 1)', () => {
|
||||||
|
jobStorage.getJobs();
|
||||||
|
expect(calls.query).toHaveLength(1);
|
||||||
|
expect(calls.query[0].sql).toMatch(/WHERE j\.enabled = 1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('includes disabled jobs when includeDisabled is true', () => {
|
||||||
|
jobStorage.getJobs({ includeDisabled: true });
|
||||||
|
expect(calls.query).toHaveLength(1);
|
||||||
|
expect(calls.query[0].sql).not.toMatch(/WHERE j\.enabled = 1/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('coerces the enabled column to a boolean', () => {
|
||||||
|
sqliteMock.__queryHandler = () => [
|
||||||
|
{ id: 'enabled-job', enabled: 1 },
|
||||||
|
{ id: 'disabled-job', enabled: 0 },
|
||||||
|
];
|
||||||
|
const jobs = jobStorage.getJobs({ includeDisabled: true });
|
||||||
|
expect(jobs.find((j) => j.id === 'enabled-job').enabled).toBe(true);
|
||||||
|
expect(jobs.find((j) => j.id === 'disabled-job').enabled).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -120,6 +120,57 @@ describe('listingsStorage.queryListings statusFilter', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('listingsStorage.queryListings hiddenOnly', () => {
|
||||||
|
let listingsStorage;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
calls.execute.length = 0;
|
||||||
|
calls.query.length = 0;
|
||||||
|
sqliteMock.__queryHandler = (sql) => {
|
||||||
|
if (/COUNT\(1\)/.test(sql)) return [{ cnt: 0 }];
|
||||||
|
return [];
|
||||||
|
};
|
||||||
|
listingsStorage = await import('../../lib/services/storage/listingsStorage.js');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by manually_deleted = 0 by default', () => {
|
||||||
|
listingsStorage.queryListings({ userId: 'u1', isAdmin: true });
|
||||||
|
const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql));
|
||||||
|
expect(pageQuery.sql).toMatch(/\(l\.manually_deleted = 0\)/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('filters by manually_deleted = 1 when hiddenOnly is true', () => {
|
||||||
|
listingsStorage.queryListings({ userId: 'u1', isAdmin: true, hiddenOnly: true });
|
||||||
|
const pageQuery = calls.query.find((c) => !/COUNT\(1\)/.test(c.sql));
|
||||||
|
expect(pageQuery.sql).toMatch(/\(l\.manually_deleted = 1\)/);
|
||||||
|
expect(pageQuery.sql).not.toMatch(/\(l\.manually_deleted = 0\)/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('listingsStorage.restoreListingsById', () => {
|
||||||
|
let listingsStorage;
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
calls.execute.length = 0;
|
||||||
|
calls.query.length = 0;
|
||||||
|
sqliteMock.__queryHandler = null;
|
||||||
|
listingsStorage = await import('../../lib/services/storage/listingsStorage.js');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the manually_deleted flag for the given ids', () => {
|
||||||
|
listingsStorage.restoreListingsById(['a', 'b']);
|
||||||
|
expect(calls.execute).toHaveLength(1);
|
||||||
|
expect(calls.execute[0].sql).toMatch(/UPDATE listings\s+SET manually_deleted = 0\s+WHERE id IN \(\?,\?\)/);
|
||||||
|
expect(calls.execute[0].params).toEqual(['a', 'b']);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('is a no-op when ids are missing or empty', () => {
|
||||||
|
listingsStorage.restoreListingsById([]);
|
||||||
|
listingsStorage.restoreListingsById(undefined);
|
||||||
|
expect(calls.execute).toHaveLength(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
describe('listingsStorage.getListingById', () => {
|
describe('listingsStorage.getListingById', () => {
|
||||||
let listingsStorage;
|
let listingsStorage;
|
||||||
|
|
||||||
|
|||||||
@@ -11,14 +11,14 @@ import GeneralSettings from './views/generalSettings/GeneralSettings';
|
|||||||
import JobMutation from './views/jobs/mutation/JobMutation';
|
import JobMutation from './views/jobs/mutation/JobMutation';
|
||||||
import UserMutator from './views/user/mutation/UserMutator';
|
import UserMutator from './views/user/mutation/UserMutator';
|
||||||
import { useActions, useSelector } from './services/state/store';
|
import { useActions, useSelector } from './services/state/store';
|
||||||
import { Routes, Route, Navigate } from 'react-router-dom';
|
import { Routes, Route, Navigate, useLocation } from 'react-router-dom';
|
||||||
import Login from './views/login/Login';
|
import Login from './views/login/Login';
|
||||||
import Users from './views/user/Users';
|
import Users from './views/user/Users';
|
||||||
import Jobs from './views/jobs/Jobs';
|
import Jobs from './views/jobs/Jobs';
|
||||||
|
|
||||||
import './App.less';
|
import './App.less';
|
||||||
import TrackingModal from './components/tracking/TrackingModal.jsx';
|
import TrackingModal from './components/tracking/TrackingModal.jsx';
|
||||||
import { Banner } from '@douyinfe/semi-ui-19';
|
import { Banner, LocaleProvider } from '@douyinfe/semi-ui-19';
|
||||||
import VersionBanner from './components/version/VersionBanner.jsx';
|
import VersionBanner from './components/version/VersionBanner.jsx';
|
||||||
import Listings from './views/listings/Listings.jsx';
|
import Listings from './views/listings/Listings.jsx';
|
||||||
import MapView from './views/listings/Map.jsx';
|
import MapView from './views/listings/Map.jsx';
|
||||||
@@ -29,13 +29,27 @@ import WatchlistManagement from './views/listings/management/WatchlistManagement
|
|||||||
import Dashboard from './views/dashboard/Dashboard.jsx';
|
import Dashboard from './views/dashboard/Dashboard.jsx';
|
||||||
import ListingDetail from './views/listings/ListingDetail.jsx';
|
import ListingDetail from './views/listings/ListingDetail.jsx';
|
||||||
import NewsModal from './components/news/NewsModal.jsx';
|
import NewsModal from './components/news/NewsModal.jsx';
|
||||||
|
import { I18nProvider, availableLanguages } from './services/i18n/i18n.jsx';
|
||||||
|
import DebugLoggingBanner from './components/debug/DebugLoggingBanner.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() {
|
export default function FredyApp() {
|
||||||
|
const location = useLocation();
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const currentUser = useSelector((state) => state.user.currentUser);
|
const currentUser = useSelector((state) => state.user.currentUser);
|
||||||
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
|
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
|
||||||
const settings = useSelector((state) => state.generalSettings.settings);
|
const settings = useSelector((state) => state.generalSettings.settings);
|
||||||
|
const language = useSelector((state) => state.userSettings.settings.language);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function init() {
|
async function init() {
|
||||||
@@ -63,10 +77,17 @@ export default function FredyApp() {
|
|||||||
const isAdmin = () => currentUser != null && currentUser.isAdmin;
|
const isAdmin = () => currentUser != null && currentUser.isAdmin;
|
||||||
const { Sider, Content } = Layout;
|
const { Sider, Content } = Layout;
|
||||||
|
|
||||||
return loading ? null : needsLogin() ? (
|
return loading ? null : (
|
||||||
|
<I18nProvider language={language ?? 'en'}>
|
||||||
|
<LocaleProvider
|
||||||
|
locale={
|
||||||
|
semiLocales[availableLanguages.find((l) => l.code === (language ?? 'en'))?.semiLocale] ?? semiLocales['en_US']
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{needsLogin() ? (
|
||||||
<Routes>
|
<Routes>
|
||||||
<Route path="/login" element={<Login />} />
|
<Route path="/login" element={<Login />} />
|
||||||
<Route path="*" element={<Navigate to="/login" replace />} />
|
<Route path="*" element={<Navigate state={{ from: location }} to="/login" replace />} />
|
||||||
</Routes>
|
</Routes>
|
||||||
) : (
|
) : (
|
||||||
<Layout className="app">
|
<Layout className="app">
|
||||||
@@ -76,6 +97,7 @@ export default function FredyApp() {
|
|||||||
<Layout className="app__main">
|
<Layout className="app__main">
|
||||||
<Content className="app__content">
|
<Content className="app__content">
|
||||||
{versionUpdate?.newVersion && <VersionBanner />}
|
{versionUpdate?.newVersion && <VersionBanner />}
|
||||||
|
<DebugLoggingBanner />
|
||||||
{settings.demoMode && (
|
{settings.demoMode && (
|
||||||
<>
|
<>
|
||||||
<Banner
|
<Banner
|
||||||
@@ -136,6 +158,9 @@ export default function FredyApp() {
|
|||||||
<FredyFooter />
|
<FredyFooter />
|
||||||
</Layout>
|
</Layout>
|
||||||
</Layout>
|
</Layout>
|
||||||
|
)}
|
||||||
|
</LocaleProvider>
|
||||||
|
</I18nProvider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
BIN
ui/src/assets/news/1.jpg
Normal file
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 |
@@ -1,16 +1,11 @@
|
|||||||
{
|
{
|
||||||
"key": "00e6b81777a275f5a140fc9101cb943810db6a69f6eb3927319c5aee0c876221",
|
"key": "00e6b81777a275f5a140fc9101cb94eef0db6a69f6eb3927319c5aee0c876221",
|
||||||
"content":
|
"content":
|
||||||
[
|
[
|
||||||
{
|
{
|
||||||
"title": "Table overview for listings",
|
"title": "Fredy goes multilingual!",
|
||||||
"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.",
|
"text": "Fredy now supports multiple languages (Starting with german and english). You can select the language in the user-settings.",
|
||||||
"media": "1.png"
|
"media": "1.jpg"
|
||||||
},
|
|
||||||
{
|
|
||||||
"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"
|
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@
|
|||||||
|
|
||||||
import { useState, useEffect } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Modal, Radio, RadioGroup, Typography, Checkbox } from '@douyinfe/semi-ui-19';
|
import { Modal, Radio, RadioGroup, Typography, Checkbox } from '@douyinfe/semi-ui-19';
|
||||||
|
import { useTranslation } from '../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -12,11 +13,14 @@ const ListingDeletionModal = ({
|
|||||||
visible,
|
visible,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
onCancel,
|
onCancel,
|
||||||
title = 'Delete Listings',
|
title,
|
||||||
showOptions = true,
|
showOptions = true,
|
||||||
message = 'How would you like to delete the selected listing(s)?',
|
message,
|
||||||
defaultDeleteType = 'soft',
|
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 [deleteType, setDeleteType] = useState('soft');
|
||||||
const [remember, setRemember] = useState(false);
|
const [remember, setRemember] = useState(false);
|
||||||
|
|
||||||
@@ -37,47 +41,41 @@ const ListingDeletionModal = ({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={title}
|
title={resolvedTitle}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onOk={handleOk}
|
onOk={handleOk}
|
||||||
onCancel={onCancel}
|
onCancel={onCancel}
|
||||||
okText="Confirm"
|
okText={t('listing.deletion.confirm')}
|
||||||
cancelText="Cancel"
|
cancelText={t('listing.deletion.cancel')}
|
||||||
style={{ maxWidth: '500px' }}
|
style={{ maxWidth: '500px' }}
|
||||||
>
|
>
|
||||||
<div style={{ marginBottom: 16 }}>
|
<div style={{ marginBottom: 16 }}>
|
||||||
<Text>{message}</Text>
|
<Text>{resolvedMessage}</Text>
|
||||||
</div>
|
</div>
|
||||||
{showOptions && (
|
{showOptions && (
|
||||||
<>
|
<>
|
||||||
<RadioGroup value={deleteType} onChange={(e) => setDeleteType(e.target.value)} style={{ width: '100%' }}>
|
<RadioGroup value={deleteType} onChange={(e) => setDeleteType(e.target.value)} style={{ width: '100%' }}>
|
||||||
<Radio value="soft" style={{ alignItems: 'flex-start', width: '100%' }}>
|
<Radio value="soft" style={{ alignItems: 'flex-start', width: '100%' }}>
|
||||||
<div style={{ marginLeft: 8 }}>
|
<div style={{ marginLeft: 8 }}>
|
||||||
<Text strong>Mark as deleted (Soft Delete)</Text>
|
<Text strong>{t('listing.deletion.softLabel')}</Text>
|
||||||
<br />
|
<br />
|
||||||
<Text type="secondary">
|
<Text type="secondary">{t('listing.deletion.softDescription')}</Text>
|
||||||
Listings are kept in the database but marked as hidden. They will <b>not</b> re-appear during the next
|
|
||||||
scraping session.
|
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
</Radio>
|
</Radio>
|
||||||
<Radio value="hard" style={{ marginTop: 16, alignItems: 'flex-start', width: '100%' }}>
|
<Radio value="hard" style={{ marginTop: 16, alignItems: 'flex-start', width: '100%' }}>
|
||||||
<div style={{ marginLeft: 8 }}>
|
<div style={{ marginLeft: 8 }}>
|
||||||
<Text strong>Remove from database (Hard Delete)</Text>
|
<Text strong>{t('listing.deletion.hardLabel')}</Text>
|
||||||
<br />
|
<br />
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
Listings are completely removed from the database.
|
{t('listing.deletion.hardDescription')}
|
||||||
<br />
|
<br />
|
||||||
<Text type="warning">
|
<Text type="warning">{t('listing.deletion.hardConsequence')}</Text>
|
||||||
Consequence: They might re-appear when scraping the next time because Fredy won't know they were
|
|
||||||
previously found.
|
|
||||||
</Text>
|
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Radio>
|
</Radio>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
<Checkbox checked={remember} onChange={(e) => setRemember(e.target.checked)} style={{ marginTop: 16 }}>
|
<Checkbox checked={remember} onChange={(e) => setRemember(e.target.checked)} style={{ marginTop: 16 }}>
|
||||||
Remember my choice and skip this dialog next time
|
{t('listing.deletion.rememberChoice')}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import { Pie } from 'react-chartjs-2';
|
|||||||
import { Chart as ChartJS, ArcElement, Tooltip, Legend, Title as ChartTitle } from 'chart.js';
|
import { Chart as ChartJS, ArcElement, Tooltip, Legend, Title as ChartTitle } from 'chart.js';
|
||||||
|
|
||||||
import './ChartCard.less';
|
import './ChartCard.less';
|
||||||
|
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
ChartJS.register(ArcElement, Tooltip, Legend, ChartTitle);
|
ChartJS.register(ArcElement, Tooltip, Legend, ChartTitle);
|
||||||
|
|
||||||
export default function PieChartCard({ data = [] }) {
|
export default function PieChartCard({ data = [] }) {
|
||||||
|
const t = useTranslation();
|
||||||
const { labels, values } = React.useMemo(() => {
|
const { labels, values } = React.useMemo(() => {
|
||||||
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
if (data && typeof data === 'object' && !Array.isArray(data)) {
|
||||||
const lbls = Array.isArray(data.labels) ? data.labels : [];
|
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;
|
const isEmpty = !labels || labels.length === 0 || !values || values.length === 0;
|
||||||
|
|
||||||
return (
|
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} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
58
ui/src/components/debug/DebugLoggingBanner.jsx
Normal file
58
ui/src/components/debug/DebugLoggingBanner.jsx
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { Banner } from '@douyinfe/semi-ui-19';
|
||||||
|
|
||||||
|
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||||
|
import { fetchDebugActive } from '../../services/debugLoggingClient.js';
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 15000;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Persistent, non-dismissable red banner shown on every page while the admin opt-in
|
||||||
|
* "Debug Logging" feature is active. Polls the lightweight `/api/debug/active` probe
|
||||||
|
* so every authenticated user (not just admins) sees the warning, without exposing
|
||||||
|
* the rest of the settings payload.
|
||||||
|
*
|
||||||
|
* Polling interval is intentionally generous (15s) because the value only changes
|
||||||
|
* when an admin toggles the feature, which happens at human speeds. The Debug tab
|
||||||
|
* itself uses its own 3s polling for the live progress bar inside Settings.
|
||||||
|
*
|
||||||
|
* @returns {JSX.Element|null}
|
||||||
|
*/
|
||||||
|
export default function DebugLoggingBanner() {
|
||||||
|
const t = useTranslation();
|
||||||
|
const [active, setActive] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
const tick = async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetchDebugActive();
|
||||||
|
if (!cancelled) setActive(Boolean(res?.enabled));
|
||||||
|
} catch {
|
||||||
|
// Best-effort probe: an unauthenticated 401 (e.g. session expired) simply
|
||||||
|
// hides the banner until the next successful poll.
|
||||||
|
}
|
||||||
|
};
|
||||||
|
tick();
|
||||||
|
const id = window.setInterval(tick, POLL_INTERVAL_MS);
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
window.clearInterval(id);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
if (!active) return null;
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Banner fullMode={true} type="danger" bordered closeIcon={null} description={t('app.debugLoggingBanner')} />
|
||||||
|
<br />
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
DebugLoggingBanner.displayName = 'DebugLoggingBanner';
|
||||||
@@ -6,16 +6,18 @@
|
|||||||
import './FredyFooter.less';
|
import './FredyFooter.less';
|
||||||
import { useSelector } from '../../services/state/store.js';
|
import { useSelector } from '../../services/state/store.js';
|
||||||
import { Layout } from '@douyinfe/semi-ui-19';
|
import { Layout } from '@douyinfe/semi-ui-19';
|
||||||
|
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
export default function FredyFooter() {
|
export default function FredyFooter() {
|
||||||
|
const t = useTranslation();
|
||||||
const { Footer } = Layout;
|
const { Footer } = Layout;
|
||||||
const version = useSelector((state) => state.versionUpdate.versionUpdate);
|
const version = useSelector((state) => state.versionUpdate.versionUpdate);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Footer className="fredyFooter">
|
<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">
|
<span className="fredyFooter__credit">
|
||||||
Made with ❤️ by{' '}
|
{t('footer.madeWith')}{' '}
|
||||||
<a href="https://github.com/orangecoding" target="_blank" rel="noreferrer">
|
<a href="https://github.com/orangecoding" target="_blank" rel="noreferrer">
|
||||||
Christian Kellner
|
Christian Kellner
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -48,12 +48,14 @@ import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-i
|
|||||||
import JobsTable from '../../table/JobsTable.jsx';
|
import JobsTable from '../../table/JobsTable.jsx';
|
||||||
|
|
||||||
import './JobGrid.less';
|
import './JobGrid.less';
|
||||||
|
import { useTranslation } from '../../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
const { Text, Title } = Typography;
|
const { Text, Title } = Typography;
|
||||||
|
|
||||||
const getPopoverContent = (text) => <article className="jobPopoverContent">{text}</article>;
|
const getPopoverContent = (text) => <article className="jobPopoverContent">{text}</article>;
|
||||||
|
|
||||||
const JobGrid = () => {
|
const JobGrid = () => {
|
||||||
|
const t = useTranslation();
|
||||||
const jobsData = useSelector((state) => state.jobsData);
|
const jobsData = useSelector((state) => state.jobsData);
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
@@ -104,7 +106,7 @@ const JobGrid = () => {
|
|||||||
actions.jobsData.setJobRunning(data.jobId, !!data.running);
|
actions.jobsData.setJobRunning(data.jobId, !!data.running);
|
||||||
// notify finish if it was triggered by this view
|
// notify finish if it was triggered by this view
|
||||||
if (pendingJobIdRef.current === data.jobId && data.running === false) {
|
if (pendingJobIdRef.current === data.jobId && data.running === false) {
|
||||||
Toast.success('Job finished');
|
Toast.success(t('jobs.toastFinished'));
|
||||||
pendingJobIdRef.current = null;
|
pendingJobIdRef.current = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,17 +163,17 @@ const JobGrid = () => {
|
|||||||
}
|
}
|
||||||
if (type === 'job') {
|
if (type === 'job') {
|
||||||
await xhrDelete('/api/jobs', { jobId });
|
await xhrDelete('/api/jobs', { jobId });
|
||||||
Toast.success('Job and listings successfully removed');
|
Toast.success(t('jobs.toastDeletedWithListings'));
|
||||||
} else if (type === 'listings') {
|
} else if (type === 'listings') {
|
||||||
await xhrDelete('/api/listings/job', { jobId, hardDelete });
|
await xhrDelete('/api/listings/job', { jobId, hardDelete });
|
||||||
Toast.success('Listings successfully removed');
|
Toast.success(t('jobs.toastListingsDeleted'));
|
||||||
}
|
}
|
||||||
loadData();
|
loadData();
|
||||||
if (type === 'job') {
|
if (type === 'job') {
|
||||||
actions.jobsData.getJobs(); // refresh select list too
|
actions.jobsData.getJobs(); // refresh select list too
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Toast.error(error.message || 'Error performing deletion');
|
Toast.error(error.message || t('jobs.toastDeleteError'));
|
||||||
} finally {
|
} finally {
|
||||||
setDeleteModalVisible(false);
|
setDeleteModalVisible(false);
|
||||||
setPendingDeletion(null);
|
setPendingDeletion(null);
|
||||||
@@ -181,8 +183,9 @@ const JobGrid = () => {
|
|||||||
const onJobStatusChanged = async (jobId, status) => {
|
const onJobStatusChanged = async (jobId, status) => {
|
||||||
try {
|
try {
|
||||||
await xhrPut(`/api/jobs/${jobId}/status`, { status });
|
await xhrPut(`/api/jobs/${jobId}/status`, { status });
|
||||||
Toast.success('Job status successfully changed');
|
Toast.success(t('jobs.toastStatusChanged'));
|
||||||
loadData();
|
loadData();
|
||||||
|
actions.jobsData.getJobs(); // refresh the jobs slice read by the edit form so its switch isn't stale
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Toast.error(error.error);
|
Toast.error(error.error);
|
||||||
}
|
}
|
||||||
@@ -192,21 +195,21 @@ const JobGrid = () => {
|
|||||||
try {
|
try {
|
||||||
const response = await xhrPost(`/api/jobs/${jobId}/run`);
|
const response = await xhrPost(`/api/jobs/${jobId}/run`);
|
||||||
if (response.status === 202) {
|
if (response.status === 202) {
|
||||||
Toast.success('Job run started');
|
Toast.success(t('jobs.toastRunStarted'));
|
||||||
} else {
|
} else {
|
||||||
Toast.info('Job run requested');
|
Toast.info(t('jobs.toastRunRequested'));
|
||||||
}
|
}
|
||||||
pendingJobIdRef.current = jobId;
|
pendingJobIdRef.current = jobId;
|
||||||
loadData();
|
loadData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error?.status === 409) {
|
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) {
|
} 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) {
|
} else if (error?.status === 404) {
|
||||||
Toast.error('Job not found');
|
Toast.error(t('jobs.toastNotFound'));
|
||||||
} else {
|
} else {
|
||||||
Toast.error('Failed to trigger job');
|
Toast.error(t('jobs.toastRunFailed'));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -222,7 +225,7 @@ const JobGrid = () => {
|
|||||||
className="jobGrid__topbar__search"
|
className="jobGrid__topbar__search"
|
||||||
prefix={<IconSearch />}
|
prefix={<IconSearch />}
|
||||||
showClear
|
showClear
|
||||||
placeholder="Search"
|
placeholder={t('jobs.searchPlaceholder')}
|
||||||
onChange={handleFilterChange}
|
onChange={handleFilterChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@@ -235,39 +238,44 @@ const JobGrid = () => {
|
|||||||
setActivityFilter(v === 'all' ? null : v === 'true');
|
setActivityFilter(v === 'all' ? null : v === 'true');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Radio value="all">All</Radio>
|
<Radio value="all">{t('jobs.filterAll')}</Radio>
|
||||||
<Radio value="true">Active</Radio>
|
<Radio value="true">{t('jobs.filterActive')}</Radio>
|
||||||
<Radio value="false">Inactive</Radio>
|
<Radio value="false">{t('jobs.filterInactive')}</Radio>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
|
|
||||||
<Select prefix="Sort by" style={{ width: 200 }} value={sortField} onChange={(val) => setSortField(val)}>
|
<Select
|
||||||
<Select.Option value="name">Name</Select.Option>
|
prefix={t('jobs.sortPrefix')}
|
||||||
<Select.Option value="numberOfFoundListings">Number of Listings</Select.Option>
|
style={{ width: 200 }}
|
||||||
<Select.Option value="enabled">Status</Select.Option>
|
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>
|
</Select>
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
|
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
|
||||||
onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
|
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">
|
<div className="jobGrid__topbar__view-toggle">
|
||||||
<Tooltip content="Grid view">
|
<Tooltip content={t('jobs.tooltipGridView')}>
|
||||||
<Button
|
<Button
|
||||||
icon={<IconGridView />}
|
icon={<IconGridView />}
|
||||||
theme={viewMode === 'grid' ? 'solid' : 'borderless'}
|
theme={viewMode === 'grid' ? 'solid' : 'borderless'}
|
||||||
onClick={() => actions.userSettings.setJobsViewMode('grid')}
|
onClick={() => actions.userSettings.setJobsViewMode('grid')}
|
||||||
aria-label="Grid view"
|
aria-label={t('common.ariaGridView')}
|
||||||
aria-pressed={viewMode === 'grid'}
|
aria-pressed={viewMode === 'grid'}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Table view">
|
<Tooltip content={t('jobs.tooltipTableView')}>
|
||||||
<Button
|
<Button
|
||||||
icon={<IconList />}
|
icon={<IconList />}
|
||||||
theme={viewMode === 'table' ? 'solid' : 'borderless'}
|
theme={viewMode === 'table' ? 'solid' : 'borderless'}
|
||||||
onClick={() => actions.userSettings.setJobsViewMode('table')}
|
onClick={() => actions.userSettings.setJobsViewMode('table')}
|
||||||
aria-label="Table view"
|
aria-label={t('common.ariaTableView')}
|
||||||
aria-pressed={viewMode === 'table'}
|
aria-pressed={viewMode === 'table'}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
@@ -278,7 +286,7 @@ const JobGrid = () => {
|
|||||||
<Empty
|
<Empty
|
||||||
image={<IllustrationNoResult />}
|
image={<IllustrationNoResult />}
|
||||||
darkModeImage={<IllustrationNoResultDark />}
|
darkModeImage={<IllustrationNoResultDark />}
|
||||||
description="No jobs available yet..."
|
description={t('jobs.empty')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -296,7 +304,7 @@ const JobGrid = () => {
|
|||||||
</div>
|
</div>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
|
||||||
{job.isOnlyShared && (
|
{job.isOnlyShared && (
|
||||||
<Popover content={getPopoverContent('This job has been shared with you - read only.')}>
|
<Popover content={getPopoverContent(t('jobs.cardSharedReadOnly'))}>
|
||||||
<div>
|
<div>
|
||||||
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
|
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
|
||||||
</div>
|
</div>
|
||||||
@@ -304,7 +312,7 @@ const JobGrid = () => {
|
|||||||
)}
|
)}
|
||||||
{job.running && (
|
{job.running && (
|
||||||
<Tag color="green" variant="light" size="small">
|
<Tag color="green" variant="light" size="small">
|
||||||
RUNNING
|
{t('jobs.cardRunning')}
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
@@ -314,19 +322,19 @@ const JobGrid = () => {
|
|||||||
<div className="jobGrid__card__stat jobGrid__card__stat--blue">
|
<div className="jobGrid__card__stat jobGrid__card__stat--blue">
|
||||||
<span className="jobGrid__card__stat__number">{job.numberOfFoundListings || 0}</span>
|
<span className="jobGrid__card__stat__number">{job.numberOfFoundListings || 0}</span>
|
||||||
<span className="jobGrid__card__stat__label">
|
<span className="jobGrid__card__stat__label">
|
||||||
<IconHome size="small" /> Listings
|
<IconHome size="small" /> {t('jobs.cardListings')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="jobGrid__card__stat jobGrid__card__stat--orange">
|
<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__number">{job.provider?.length || 0}</span>
|
||||||
<span className="jobGrid__card__stat__label">
|
<span className="jobGrid__card__stat__label">
|
||||||
<IconBriefcase size="small" /> Providers
|
<IconBriefcase size="small" /> {t('jobs.cardProviders')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="jobGrid__card__stat jobGrid__card__stat--purple">
|
<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__number">{job.notificationAdapter?.length || 0}</span>
|
||||||
<span className="jobGrid__card__stat__label">
|
<span className="jobGrid__card__stat__label">
|
||||||
<IconBell size="small" /> Adapters
|
<IconBell size="small" /> {t('jobs.cardAdapters')}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -342,11 +350,11 @@ const JobGrid = () => {
|
|||||||
size="small"
|
size="small"
|
||||||
/>
|
/>
|
||||||
<Text type="secondary" size="small">
|
<Text type="secondary" size="small">
|
||||||
Active
|
{t('jobs.cardActive')}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<div className="jobGrid__actions">
|
<div className="jobGrid__actions">
|
||||||
<Popover content={getPopoverContent('Run Job')}>
|
<Popover content={getPopoverContent(t('jobs.popoverRunJob'))}>
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -359,7 +367,7 @@ const JobGrid = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={getPopoverContent('Edit a Job')}>
|
<Popover content={getPopoverContent(t('jobs.popoverEditJob'))}>
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
type="secondary"
|
type="secondary"
|
||||||
@@ -370,7 +378,7 @@ const JobGrid = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={getPopoverContent('Clone Job')}>
|
<Popover content={getPopoverContent(t('jobs.popoverCloneJob'))}>
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
@@ -381,7 +389,7 @@ const JobGrid = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={getPopoverContent('Delete all found Listings of this Job')}>
|
<Popover content={getPopoverContent(t('jobs.popoverDeleteListings'))}>
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
type="danger"
|
type="danger"
|
||||||
@@ -392,7 +400,7 @@ const JobGrid = () => {
|
|||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Popover>
|
</Popover>
|
||||||
<Popover content={getPopoverContent('Delete Job')}>
|
<Popover content={getPopoverContent(t('jobs.popoverDeleteJob'))}>
|
||||||
<div>
|
<div>
|
||||||
<Button
|
<Button
|
||||||
type="danger"
|
type="danger"
|
||||||
@@ -433,14 +441,10 @@ const JobGrid = () => {
|
|||||||
)}
|
)}
|
||||||
<ListingDeletionModal
|
<ListingDeletionModal
|
||||||
visible={deleteModalVisible}
|
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'}
|
showOptions={pendingDeletion?.type !== 'job'}
|
||||||
defaultDeleteType={defaultDeleteType}
|
defaultDeleteType={defaultDeleteType}
|
||||||
message={
|
message={pendingDeletion?.type === 'job' ? t('jobs.deletion.message') : t('listing.deletion.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)?'
|
|
||||||
}
|
|
||||||
onConfirm={confirmDeletion}
|
onConfirm={confirmDeletion}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setDeleteModalVisible(false);
|
setDeleteModalVisible(false);
|
||||||
|
|||||||
@@ -19,11 +19,15 @@ import * as timeService from '../../../services/time/timeService.js';
|
|||||||
import StatusControl from '../../listings/StatusControl.jsx';
|
import StatusControl from '../../listings/StatusControl.jsx';
|
||||||
|
|
||||||
import './ListingsGrid.less';
|
import './ListingsGrid.less';
|
||||||
|
import { useTranslation, useLocale } from '../../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onStatusChange: Function }} props
|
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onRestore?: Function, isHiddenView?: boolean, onStatusChange: Function }} props
|
||||||
*/
|
*/
|
||||||
const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange }) => (
|
const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onRestore, isHiddenView = false, onStatusChange }) => {
|
||||||
|
const t = useTranslation();
|
||||||
|
const locale = useLocale();
|
||||||
|
return (
|
||||||
<div className="listingsGrid__grid">
|
<div className="listingsGrid__grid">
|
||||||
{listings.map((item) => (
|
{listings.map((item) => (
|
||||||
<div
|
<div
|
||||||
@@ -47,15 +51,21 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
|
|||||||
/>
|
/>
|
||||||
{!item.is_active && (
|
{!item.is_active && (
|
||||||
<div className="listingsGrid__card__inactive-watermark">
|
<div className="listingsGrid__card__inactive-watermark">
|
||||||
<span>Inactive</span>
|
<span>{t('listings.cardInactive')}</span>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<Tooltip content={item.isWatched === 1 ? 'Remove from Watchlist' : 'Add to Watchlist'}>
|
<Tooltip
|
||||||
|
content={
|
||||||
|
item.isWatched === 1 ? t('listings.tooltipRemoveFromWatchlist') : t('listings.tooltipAddToWatchlist')
|
||||||
|
}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="listingsGrid__card__star"
|
className="listingsGrid__card__star"
|
||||||
onClick={(e) => onWatch(e, item)}
|
onClick={(e) => onWatch(e, item)}
|
||||||
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
|
aria-label={
|
||||||
|
item.isWatched === 1 ? t('listings.tooltipRemoveFromWatchlist') : t('listings.tooltipAddToWatchlist')
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
|
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
|
||||||
</button>
|
</button>
|
||||||
@@ -82,7 +92,7 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
|
|||||||
<IconBriefcase />
|
<IconBriefcase />
|
||||||
{item.provider}
|
{item.provider}
|
||||||
</div>
|
</div>
|
||||||
<div className="listingsGrid__card__provider">{timeService.format(item.created_at, false)}</div>
|
<div className="listingsGrid__card__provider">{timeService.format(item.created_at, false, locale)}</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="listingsGrid__card__actions" onClick={(e) => e.stopPropagation()}>
|
<div className="listingsGrid__card__actions" onClick={(e) => e.stopPropagation()}>
|
||||||
@@ -92,7 +102,7 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
|
|||||||
onChange={(next) => onStatusChange?.(item, next)}
|
onChange={(next) => onStatusChange?.(item, next)}
|
||||||
onTriggerClick={(e) => e.stopPropagation()}
|
onTriggerClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
<Tooltip content="Original Listing">
|
<Tooltip content={t('listings.tooltipOriginalListing')}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<IconLink />}
|
icon={<IconLink />}
|
||||||
@@ -104,7 +114,7 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="View in Fredy">
|
<Tooltip content={t('listings.tooltipViewInFredy')}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<IconEyeOpened />}
|
icon={<IconEyeOpened />}
|
||||||
@@ -116,7 +126,26 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Remove">
|
{isHiddenView ? (
|
||||||
|
<Tooltip content={t('listings.tooltipUndelete')}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={
|
||||||
|
<span className="listingsGrid__strike" aria-hidden="true">
|
||||||
|
<IconDelete />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
style={{ color: '#34d399' }}
|
||||||
|
theme="borderless"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRestore?.(item.id);
|
||||||
|
}}
|
||||||
|
aria-label={t('listings.tooltipUndelete')}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Tooltip content={t('listings.tooltipRemove')}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<IconDelete />}
|
icon={<IconDelete />}
|
||||||
@@ -128,10 +157,12 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default ListingsGrid;
|
export default ListingsGrid;
|
||||||
|
|||||||
@@ -139,4 +139,23 @@
|
|||||||
border-radius: @radius-chip !important;
|
border-radius: @radius-chip !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__strike {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -2px;
|
||||||
|
right: -2px;
|
||||||
|
top: 50%;
|
||||||
|
height: 2px;
|
||||||
|
background: currentColor;
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,18 @@ import {
|
|||||||
parseString,
|
parseString,
|
||||||
parseNullableBoolean,
|
parseNullableBoolean,
|
||||||
} from '../../hooks/useSearchParamState.js';
|
} from '../../hooks/useSearchParamState.js';
|
||||||
import { Button, Pagination, Toast, Input, Select, Empty, Radio, RadioGroup, Tooltip } from '@douyinfe/semi-ui-19';
|
import {
|
||||||
|
Button,
|
||||||
|
Pagination,
|
||||||
|
Toast,
|
||||||
|
Input,
|
||||||
|
Select,
|
||||||
|
Empty,
|
||||||
|
Radio,
|
||||||
|
RadioGroup,
|
||||||
|
Tooltip,
|
||||||
|
Banner,
|
||||||
|
} from '@douyinfe/semi-ui-19';
|
||||||
import { IconSearch, IconArrowUp, IconArrowDown, IconGridView, IconList } from '@douyinfe/semi-icons';
|
import { IconSearch, IconArrowUp, IconArrowDown, IconGridView, IconList } from '@douyinfe/semi-icons';
|
||||||
import { useNavigate, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import ListingDeletionModal from '../ListingDeletionModal.jsx';
|
import ListingDeletionModal from '../ListingDeletionModal.jsx';
|
||||||
@@ -22,8 +33,10 @@ import ListingsTable from '../table/ListingsTable.jsx';
|
|||||||
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-illustrations';
|
||||||
|
|
||||||
import './ListingsOverview.less';
|
import './ListingsOverview.less';
|
||||||
|
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
const ListingsOverview = ({ mode = 'all' }) => {
|
const ListingsOverview = ({ mode = 'all' }) => {
|
||||||
|
const t = useTranslation();
|
||||||
const isWatchlistMode = mode === 'watchlist';
|
const isWatchlistMode = mode === 'watchlist';
|
||||||
const listingsData = useSelector((state) => state.listingsData);
|
const listingsData = useSelector((state) => state.listingsData);
|
||||||
const providers = useSelector((state) => state.provider);
|
const providers = useSelector((state) => state.provider);
|
||||||
@@ -48,9 +61,12 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
|||||||
const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean);
|
const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean);
|
||||||
const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString);
|
const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString);
|
||||||
const [statusFilter, setStatusFilter] = useSearchParamState(sp, 'status', null, parseString);
|
const [statusFilter, setStatusFilter] = useSearchParamState(sp, 'status', null, parseString);
|
||||||
|
const [hiddenOnly, setHiddenOnly] = useSearchParamState(sp, 'hidden', false, parseNullableBoolean);
|
||||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||||
const [listingToDelete, setListingToDelete] = useState(null);
|
const [listingToDelete, setListingToDelete] = useState(null);
|
||||||
|
|
||||||
|
const isHiddenView = hiddenOnly === true;
|
||||||
|
|
||||||
// In watchlist mode the watch filter is forced to "watched only" — regardless of the URL.
|
// In watchlist mode the watch filter is forced to "watched only" — regardless of the URL.
|
||||||
const effectiveWatchListFilter = isWatchlistMode ? true : watchListFilter;
|
const effectiveWatchListFilter = isWatchlistMode ? true : watchListFilter;
|
||||||
|
|
||||||
@@ -64,9 +80,10 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
|||||||
filter: {
|
filter: {
|
||||||
watchListFilter: effectiveWatchListFilter,
|
watchListFilter: effectiveWatchListFilter,
|
||||||
jobNameFilter,
|
jobNameFilter,
|
||||||
activityFilter,
|
activityFilter: isHiddenView ? null : activityFilter,
|
||||||
providerFilter,
|
providerFilter,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
|
hiddenOnly: isHiddenView ? true : undefined,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -83,6 +100,7 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
|||||||
jobNameFilter,
|
jobNameFilter,
|
||||||
watchListFilter,
|
watchListFilter,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
|
hiddenOnly,
|
||||||
isWatchlistMode,
|
isWatchlistMode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -106,22 +124,24 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
|||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
try {
|
try {
|
||||||
await xhrPost('/api/listings/watch', { listingId: item.id });
|
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();
|
loadData();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
Toast.error('Failed to operate Watchlist');
|
Toast.error(t('listings.toastWatchlistError'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleStatusChange = async (item, nextStatus) => {
|
const handleStatusChange = async (item, nextStatus) => {
|
||||||
try {
|
try {
|
||||||
await actions.listingsData.setListingStatus(item.id, nextStatus);
|
await actions.listingsData.setListingStatus(item.id, nextStatus);
|
||||||
Toast.success(nextStatus ? `Marked as ${nextStatus}` : 'Status cleared');
|
Toast.success(nextStatus ? `Marked as ${nextStatus}` : t('listings.toastStatusCleared'));
|
||||||
loadData();
|
loadData();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
Toast.error('Failed to update status');
|
Toast.error(t('listings.toastStatusUpdateError'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -134,7 +154,21 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
|||||||
setDeleteModalVisible(true);
|
setDeleteModalVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNavigate = (id) => navigate(`/listings/listing/${id}`);
|
const handleRestore = async (id) => {
|
||||||
|
try {
|
||||||
|
await actions.listingsData.restoreListings([id]);
|
||||||
|
Toast.success(t('listings.toastRestored'));
|
||||||
|
loadData();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
Toast.error(t('listings.toastRestoreError'));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleNavigate = (id) => {
|
||||||
|
if (isHiddenView) return;
|
||||||
|
navigate(`/listings/listing/${id}`);
|
||||||
|
};
|
||||||
|
|
||||||
const confirmDeletion = async (hardDelete, remember, id = listingToDelete) => {
|
const confirmDeletion = async (hardDelete, remember, id = listingToDelete) => {
|
||||||
try {
|
try {
|
||||||
@@ -142,10 +176,10 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
|||||||
await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete });
|
await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete });
|
||||||
}
|
}
|
||||||
await xhrDelete('/api/listings/', { ids: [id], hardDelete });
|
await xhrDelete('/api/listings/', { ids: [id], hardDelete });
|
||||||
Toast.success('Listing successfully removed');
|
Toast.success(t('listings.toastDeleted'));
|
||||||
loadData();
|
loadData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Toast.error(error.message || 'Error deleting listing');
|
Toast.error(error.message || t('listings.toastDeleteError'));
|
||||||
} finally {
|
} finally {
|
||||||
setDeleteModalVisible(false);
|
setDeleteModalVisible(false);
|
||||||
setListingToDelete(null);
|
setListingToDelete(null);
|
||||||
@@ -154,34 +188,52 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
|||||||
|
|
||||||
const listings = listingsData?.result || [];
|
const listings = listingsData?.result || [];
|
||||||
|
|
||||||
|
const activityRadioValue = isHiddenView ? 'hidden' : activityFilter === null ? 'all' : String(activityFilter);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="listingsOverview">
|
<div className="listingsOverview">
|
||||||
<div className="listingsOverview__topbar">
|
<div className="listingsOverview__topbar">
|
||||||
|
<Tooltip content={t('listings.filterSearchHelp')} trigger="hover" position="top">
|
||||||
|
<span className="listingsOverview__topbar__tooltipWrap listingsOverview__topbar__search">
|
||||||
<Input
|
<Input
|
||||||
className="listingsOverview__topbar__search"
|
|
||||||
prefix={<IconSearch />}
|
prefix={<IconSearch />}
|
||||||
showClear
|
showClear
|
||||||
placeholder="Search"
|
placeholder={t('listings.searchPlaceholder')}
|
||||||
defaultValue={freeTextFilter ?? ''}
|
defaultValue={freeTextFilter ?? ''}
|
||||||
onChange={handleFilterChange}
|
onChange={handleFilterChange}
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip content={t('listings.filterActivityHelp')} trigger="hover" position="top">
|
||||||
|
<span className="listingsOverview__topbar__tooltipWrap">
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
type="button"
|
type="button"
|
||||||
buttonSize="middle"
|
buttonSize="middle"
|
||||||
value={activityFilter === null ? 'all' : String(activityFilter)}
|
value={activityRadioValue}
|
||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const v = e.target.value;
|
const v = e.target.value;
|
||||||
|
if (v === 'hidden') {
|
||||||
|
setHiddenOnly(true);
|
||||||
|
setActivityFilter(null);
|
||||||
|
} else {
|
||||||
|
setHiddenOnly(false);
|
||||||
setActivityFilter(v === 'all' ? null : v === 'true');
|
setActivityFilter(v === 'all' ? null : v === 'true');
|
||||||
|
}
|
||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Radio value="all">All</Radio>
|
<Radio value="all">{t('listings.filterAll')}</Radio>
|
||||||
<Radio value="true">Active</Radio>
|
<Radio value="true">{t('listings.filterActive')}</Radio>
|
||||||
<Radio value="false">Inactive</Radio>
|
<Radio value="false">{t('listings.filterInactive')}</Radio>
|
||||||
|
<Radio value="hidden">{t('listings.filterHidden')}</Radio>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{!isWatchlistMode && (
|
{!isWatchlistMode && (
|
||||||
|
<Tooltip content={t('listings.filterWatchHelp')} trigger="hover" position="top">
|
||||||
|
<span className="listingsOverview__topbar__tooltipWrap">
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
type="button"
|
type="button"
|
||||||
buttonSize="middle"
|
buttonSize="middle"
|
||||||
@@ -192,14 +244,18 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
|||||||
setPage(1);
|
setPage(1);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<Radio value="all">All</Radio>
|
<Radio value="all">{t('listings.filterAll')}</Radio>
|
||||||
<Radio value="true">Watched</Radio>
|
<Radio value="true">{t('listings.filterWatched')}</Radio>
|
||||||
<Radio value="false">Unwatched</Radio>
|
<Radio value="false">{t('listings.filterUnwatched')}</Radio>
|
||||||
</RadioGroup>
|
</RadioGroup>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<Tooltip content={t('listings.filterStatusHelp')} trigger="hover" position="top">
|
||||||
|
<span className="listingsOverview__topbar__tooltipWrap">
|
||||||
<Select
|
<Select
|
||||||
placeholder="Status"
|
placeholder={t('listings.filterStatusPlaceholder')}
|
||||||
showClear
|
showClear
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
setStatusFilter(val ?? null);
|
setStatusFilter(val ?? null);
|
||||||
@@ -208,14 +264,18 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
|||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
style={{ width: 150 }}
|
style={{ width: 150 }}
|
||||||
>
|
>
|
||||||
<Select.Option value="applied">Applied</Select.Option>
|
<Select.Option value="applied">{t('listings.filterStatusApplied')}</Select.Option>
|
||||||
<Select.Option value="rejected">Rejected</Select.Option>
|
<Select.Option value="rejected">{t('listings.filterStatusRejected')}</Select.Option>
|
||||||
<Select.Option value="accepted">Accepted</Select.Option>
|
<Select.Option value="accepted">{t('listings.filterStatusAccepted')}</Select.Option>
|
||||||
<Select.Option value="none">No status</Select.Option>
|
<Select.Option value="none">{t('listings.filterStatusNone')}</Select.Option>
|
||||||
</Select>
|
</Select>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip content={t('listings.filterProviderHelp')} trigger="hover" position="top">
|
||||||
|
<span className="listingsOverview__topbar__tooltipWrap">
|
||||||
<Select
|
<Select
|
||||||
placeholder="Provider"
|
placeholder={t('listings.filterProviderPlaceholder')}
|
||||||
showClear
|
showClear
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
setProviderFilter(val);
|
setProviderFilter(val);
|
||||||
@@ -230,9 +290,13 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
|||||||
</Select.Option>
|
</Select.Option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip content={t('listings.filterJobHelp')} trigger="hover" position="top">
|
||||||
|
<span className="listingsOverview__topbar__tooltipWrap">
|
||||||
<Select
|
<Select
|
||||||
placeholder="Job"
|
placeholder={t('listings.filterJobPlaceholder')}
|
||||||
showClear
|
showClear
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
setJobNameFilter(val);
|
setJobNameFilter(val);
|
||||||
@@ -247,53 +311,76 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
|||||||
</Select.Option>
|
</Select.Option>
|
||||||
))}
|
))}
|
||||||
</Select>
|
</Select>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip content={t('listings.filterSortHelp')} trigger="hover" position="top">
|
||||||
|
<span className="listingsOverview__topbar__tooltipWrap listingsOverview__topbar__sort">
|
||||||
<Select
|
<Select
|
||||||
prefix="Sort by"
|
prefix={t('listings.sortPrefix')}
|
||||||
className="listingsOverview__topbar__sort"
|
|
||||||
style={{ width: 220 }}
|
style={{ width: 220 }}
|
||||||
value={sortField}
|
value={sortField}
|
||||||
onChange={(val) => setSortField(val)}
|
onChange={(val) => setSortField(val)}
|
||||||
>
|
>
|
||||||
<Select.Option value="job_name">Job Name</Select.Option>
|
<Select.Option value="job_name">{t('listings.sortByJobName')}</Select.Option>
|
||||||
<Select.Option value="created_at">Listing Date</Select.Option>
|
<Select.Option value="created_at">{t('listings.sortByDate')}</Select.Option>
|
||||||
<Select.Option value="price">Price</Select.Option>
|
<Select.Option value="price">{t('listings.sortByPrice')}</Select.Option>
|
||||||
<Select.Option value="provider">Provider</Select.Option>
|
<Select.Option value="provider">{t('listings.sortByProvider')}</Select.Option>
|
||||||
</Select>
|
</Select>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
|
<Tooltip
|
||||||
|
content={sortDir === 'asc' ? t('listings.sortAscending') : t('listings.sortDescending')}
|
||||||
|
trigger="hover"
|
||||||
|
position="top"
|
||||||
|
>
|
||||||
|
<span className="listingsOverview__topbar__tooltipWrap">
|
||||||
<Button
|
<Button
|
||||||
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
|
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
|
||||||
onClick={() => setSortDir(sortDir === 'asc' ? 'desc' : 'asc')}
|
onClick={() => setSortDir(sortDir === 'asc' ? 'desc' : 'asc')}
|
||||||
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
|
aria-label={sortDir === 'asc' ? t('listings.sortAscending') : t('listings.sortDescending')}
|
||||||
/>
|
/>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<div className="listingsOverview__topbar__view-toggle">
|
<div className="listingsOverview__topbar__view-toggle">
|
||||||
<Tooltip content="Grid view">
|
<Tooltip content={t('listings.tooltipGridView')}>
|
||||||
<Button
|
<Button
|
||||||
icon={<IconGridView />}
|
icon={<IconGridView />}
|
||||||
theme={viewMode === 'grid' ? 'solid' : 'borderless'}
|
theme={viewMode === 'grid' ? 'solid' : 'borderless'}
|
||||||
onClick={() => actions.userSettings.setListingsViewMode('grid')}
|
onClick={() => actions.userSettings.setListingsViewMode('grid')}
|
||||||
aria-label="Grid view"
|
aria-label={t('common.ariaGridView')}
|
||||||
aria-pressed={viewMode === 'grid'}
|
aria-pressed={viewMode === 'grid'}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Table view">
|
<Tooltip content={t('listings.tooltipTableView')}>
|
||||||
<Button
|
<Button
|
||||||
icon={<IconList />}
|
icon={<IconList />}
|
||||||
theme={viewMode === 'table' ? 'solid' : 'borderless'}
|
theme={viewMode === 'table' ? 'solid' : 'borderless'}
|
||||||
onClick={() => actions.userSettings.setListingsViewMode('table')}
|
onClick={() => actions.userSettings.setListingsViewMode('table')}
|
||||||
aria-label="Table view"
|
aria-label={t('common.ariaTableView')}
|
||||||
aria-pressed={viewMode === 'table'}
|
aria-pressed={viewMode === 'table'}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{isHiddenView && (
|
||||||
|
<Banner
|
||||||
|
type="info"
|
||||||
|
fullMode={false}
|
||||||
|
closeIcon={null}
|
||||||
|
description={t('listings.hiddenViewBanner')}
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
{listings.length === 0 && (
|
{listings.length === 0 && (
|
||||||
<Empty
|
<Empty
|
||||||
image={<IllustrationNoResult />}
|
image={<IllustrationNoResult />}
|
||||||
darkModeImage={<IllustrationNoResultDark />}
|
darkModeImage={<IllustrationNoResultDark />}
|
||||||
description="No listings available yet..."
|
description={t('listings.empty')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -303,6 +390,8 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
|||||||
onWatch={handleWatch}
|
onWatch={handleWatch}
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onRestore={handleRestore}
|
||||||
|
isHiddenView={isHiddenView}
|
||||||
onStatusChange={handleStatusChange}
|
onStatusChange={handleStatusChange}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -311,6 +400,8 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
|||||||
onWatch={handleWatch}
|
onWatch={handleWatch}
|
||||||
onNavigate={handleNavigate}
|
onNavigate={handleNavigate}
|
||||||
onDelete={handleDelete}
|
onDelete={handleDelete}
|
||||||
|
onRestore={handleRestore}
|
||||||
|
isHiddenView={isHiddenView}
|
||||||
onStatusChange={handleStatusChange}
|
onStatusChange={handleStatusChange}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -8,6 +8,15 @@
|
|||||||
margin-bottom: @space-4;
|
margin-bottom: @space-4;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
|
|
||||||
|
&__tooltipWrap {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
> * {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__search {
|
&__search {
|
||||||
min-width: 200px;
|
min-width: 200px;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
|||||||
@@ -8,27 +8,12 @@ import { Dropdown, Button, Tooltip } from '@douyinfe/semi-ui-19';
|
|||||||
import { IconChevronDown } from '@douyinfe/semi-icons';
|
import { IconChevronDown } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
import './StatusControl.less';
|
import './StatusControl.less';
|
||||||
|
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||||
const 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.';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @typedef {('applied'|'rejected'|'accepted'|null)} ListingStatus
|
* @typedef {('applied'|'rejected'|'accepted'|null)} ListingStatus
|
||||||
*/
|
*/
|
||||||
|
|
||||||
const STATUS_OPTIONS = [
|
|
||||||
{ value: null, label: 'None' },
|
|
||||||
{ value: 'applied', label: 'Applied' },
|
|
||||||
{ value: 'rejected', label: 'Rejected' },
|
|
||||||
{ value: 'accepted', label: 'Accepted' },
|
|
||||||
];
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Look up the option metadata for a status value.
|
|
||||||
* @param {ListingStatus} status
|
|
||||||
*/
|
|
||||||
const optionFor = (status) => STATUS_OPTIONS.find((o) => o.value === status) ?? STATUS_OPTIONS[0];
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Shared control for setting a listing's user-decision status
|
* Shared control for setting a listing's user-decision status
|
||||||
* (Applied / Rejected / Accepted).
|
* (Applied / Rejected / Accepted).
|
||||||
@@ -44,8 +29,21 @@ const optionFor = (status) => STATUS_OPTIONS.find((o) => o.value === status) ??
|
|||||||
* @param {(e: React.MouseEvent) => void} [props.onTriggerClick] - Optional click handler to stop propagation on the trigger.
|
* @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 }) {
|
export default function StatusControl({ status = null, onChange, compact = false, onTriggerClick }) {
|
||||||
|
const t = useTranslation();
|
||||||
const [open, setOpen] = useState(false);
|
const [open, setOpen] = useState(false);
|
||||||
const [tooltipOpen, setTooltipOpen] = 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 current = optionFor(status);
|
||||||
|
|
||||||
const handlePick = (next) => {
|
const handlePick = (next) => {
|
||||||
@@ -94,7 +92,7 @@ export default function StatusControl({ status = null, onChange, compact = false
|
|||||||
}}
|
}}
|
||||||
className={className}
|
className={className}
|
||||||
>
|
>
|
||||||
{status ? current.label : 'Status'}
|
{status ? current.label : t('listings.status.statusLabel')}
|
||||||
</Button>
|
</Button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import { useLocation, useNavigate } from 'react-router-dom';
|
|||||||
|
|
||||||
import './Navigate.less';
|
import './Navigate.less';
|
||||||
import { useScreenWidth } from '../../hooks/screenWidth.js';
|
import { useScreenWidth } from '../../hooks/screenWidth.js';
|
||||||
|
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
export default function Navigation({ isAdmin }) {
|
export default function Navigation({ isAdmin }) {
|
||||||
|
const t = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
|
|
||||||
@@ -28,16 +30,16 @@ export default function Navigation({ isAdmin }) {
|
|||||||
}, [width]);
|
}, [width]);
|
||||||
|
|
||||||
const items = [
|
const items = [
|
||||||
{ itemKey: '/dashboard', text: 'Dashboard', icon: <IconHistogram /> },
|
{ itemKey: '/dashboard', text: t('nav.dashboard'), icon: <IconHistogram /> },
|
||||||
{ itemKey: '/jobs', text: 'Jobs', icon: <IconTerminal /> },
|
{ itemKey: '/jobs', text: t('nav.jobs'), icon: <IconTerminal /> },
|
||||||
{
|
{
|
||||||
itemKey: 'listings',
|
itemKey: 'listings',
|
||||||
text: 'Listings',
|
text: t('nav.listings'),
|
||||||
icon: <IconStar />,
|
icon: <IconStar />,
|
||||||
items: [
|
items: [
|
||||||
{ itemKey: '/listings', text: 'Overview' },
|
{ itemKey: '/listings', text: t('nav.listingsOverview') },
|
||||||
{ itemKey: '/map', text: 'Map View' },
|
{ itemKey: '/map', text: t('nav.mapView') },
|
||||||
{ itemKey: '/listings/watchlist', text: 'Watchlist' },
|
{ itemKey: '/listings/watchlist', text: t('nav.watchlist') },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
@@ -45,19 +47,19 @@ export default function Navigation({ isAdmin }) {
|
|||||||
if (isAdmin) {
|
if (isAdmin) {
|
||||||
items.push({
|
items.push({
|
||||||
itemKey: 'settings',
|
itemKey: 'settings',
|
||||||
text: 'Settings',
|
text: t('nav.settings'),
|
||||||
icon: <IconSetting />,
|
icon: <IconSetting />,
|
||||||
items: [
|
items: [
|
||||||
{ itemKey: '/users', text: 'User Management' },
|
{ itemKey: '/users', text: t('nav.userManagement') },
|
||||||
{ itemKey: '/generalSettings', text: 'Settings' },
|
{ itemKey: '/generalSettings', text: t('nav.settingsPage') },
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
items.push({
|
items.push({
|
||||||
itemKey: 'settings',
|
itemKey: 'settings',
|
||||||
text: 'Settings',
|
text: t('nav.settings'),
|
||||||
icon: <IconSetting />,
|
icon: <IconSetting />,
|
||||||
items: [{ itemKey: '/generalSettings', text: 'Settings' }],
|
items: [{ itemKey: '/generalSettings', text: t('nav.settingsPage') }],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,8 +92,15 @@ export default function Navigation({ isAdmin }) {
|
|||||||
items={items}
|
items={items}
|
||||||
isCollapsed={collapsed}
|
isCollapsed={collapsed}
|
||||||
selectedKeys={[parsePathName(location.pathname)]}
|
selectedKeys={[parsePathName(location.pathname)]}
|
||||||
onSelect={(key) => {
|
onClick={({ itemKey }) => {
|
||||||
navigate(key.itemKey);
|
// Use onClick (fires on every click) instead of onSelect (skips the
|
||||||
|
// already-selected item) so clicking e.g. "Jobs" while on a nested
|
||||||
|
// route like /jobs/edit/:id still navigates back to the list. Only
|
||||||
|
// leaf routes navigate; parent items (keys without a leading '/') just
|
||||||
|
// toggle their submenu.
|
||||||
|
if (typeof itemKey === 'string' && itemKey.startsWith('/')) {
|
||||||
|
navigate(itemKey);
|
||||||
|
}
|
||||||
}}
|
}}
|
||||||
header={
|
header={
|
||||||
<div className="navigate__header">
|
<div className="navigate__header">
|
||||||
@@ -104,7 +113,7 @@ export default function Navigation({ isAdmin }) {
|
|||||||
<button
|
<button
|
||||||
className="navigate__toggle-btn"
|
className="navigate__toggle-btn"
|
||||||
onClick={() => setCollapsed(!collapsed)}
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
title={collapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
title={collapsed ? t('nav.expandSidebar') : t('nav.collapseSidebar')}
|
||||||
>
|
>
|
||||||
<IconSidebar size="default" />
|
<IconSidebar size="default" />
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import newsConfig from '../../assets/news/news.json';
|
|||||||
import { useActions, useSelector } from '../../services/state/store';
|
import { useActions, useSelector } from '../../services/state/store';
|
||||||
|
|
||||||
import './NewsModal.less';
|
import './NewsModal.less';
|
||||||
|
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
const newsMedia = import.meta.glob('../../assets/news/*', { eager: true, query: '?url', import: 'default' });
|
const newsMedia = import.meta.glob('../../assets/news/*', { eager: true, query: '?url', import: 'default' });
|
||||||
|
|
||||||
const NewsModal = () => {
|
const NewsModal = () => {
|
||||||
|
const t = useTranslation();
|
||||||
const screenWidth = useScreenWidth();
|
const screenWidth = useScreenWidth();
|
||||||
const newsHash = useSelector((state) => state.userSettings.settings.news_hash);
|
const newsHash = useSelector((state) => state.userSettings.settings.news_hash);
|
||||||
const userSettingsLoaded = useSelector((state) => state.userSettings.loaded);
|
const userSettingsLoaded = useSelector((state) => state.userSettings.loaded);
|
||||||
@@ -38,7 +40,7 @@ const NewsModal = () => {
|
|||||||
(item.media.includes('mp4') ? (
|
(item.media.includes('mp4') ? (
|
||||||
<video controls width="500">
|
<video controls width="500">
|
||||||
<source src={newsMedia[`../../assets/news/${item.media}`]} type="video/mp4" />
|
<source src={newsMedia[`../../assets/news/${item.media}`]} type="video/mp4" />
|
||||||
Your browser does not support the video tag.
|
{t('news.videoFallback')}
|
||||||
</video>
|
</video>
|
||||||
) : (
|
) : (
|
||||||
<img
|
<img
|
||||||
|
|||||||
@@ -4,13 +4,15 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import insufficientPermission from '../../assets/insufficient_permission.png';
|
import insufficientPermission from '../../assets/insufficient_permission.png';
|
||||||
|
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
export default function InsufficientPermission() {
|
export default function InsufficientPermission() {
|
||||||
|
const t = useTranslation();
|
||||||
return (
|
return (
|
||||||
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flexDirection: 'column' }}>
|
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flexDirection: 'column' }}>
|
||||||
<img src={insufficientPermission} height={250} />
|
<img src={insufficientPermission} height={250} />
|
||||||
<br />
|
<br />
|
||||||
<h4>Insufficient permission :(</h4>
|
<h4>{t('permission.title')}</h4>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,11 +17,14 @@ import {
|
|||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
import './JobsTable.less';
|
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
|
* @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 }) => (
|
const JobsTable = ({ jobs, onRun, onEdit, onClone, onDeleteListings, onDeleteJob, onStatusChange }) => {
|
||||||
|
const t = useTranslation();
|
||||||
|
return (
|
||||||
<div className="jobsTable">
|
<div className="jobsTable">
|
||||||
{jobs.map((job) => (
|
{jobs.map((job) => (
|
||||||
<div key={job.id} className={`jobsTable__row${!job.enabled ? ' jobsTable__row--inactive' : ''}`}>
|
<div key={job.id} className={`jobsTable__row${!job.enabled ? ' jobsTable__row--inactive' : ''}`}>
|
||||||
@@ -59,11 +62,11 @@ const JobsTable = ({ jobs, onRun, onEdit, onClone, onDeleteListings, onDeleteJob
|
|||||||
/>
|
/>
|
||||||
{job.running && (
|
{job.running && (
|
||||||
<Tag color="green" variant="light" size="small">
|
<Tag color="green" variant="light" size="small">
|
||||||
RUNNING
|
{t('jobs.cardRunning')}
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
{job.isOnlyShared && (
|
{job.isOnlyShared && (
|
||||||
<Tooltip content="Shared with you - read only">
|
<Tooltip content={t('jobs.tableSharedTooltip')}>
|
||||||
<span style={{ display: 'flex', alignItems: 'center' }}>
|
<span style={{ display: 'flex', alignItems: 'center' }}>
|
||||||
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
|
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
|
||||||
</span>
|
</span>
|
||||||
@@ -72,7 +75,7 @@ const JobsTable = ({ jobs, onRun, onEdit, onClone, onDeleteListings, onDeleteJob
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="jobsTable__row__actions">
|
<div className="jobsTable__row__actions">
|
||||||
<Tooltip content="Run Job">
|
<Tooltip content={t('jobs.tableRunJob')}>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
style={{ background: '#21aa21b5' }}
|
style={{ background: '#21aa21b5' }}
|
||||||
@@ -83,7 +86,7 @@ const JobsTable = ({ jobs, onRun, onEdit, onClone, onDeleteListings, onDeleteJob
|
|||||||
onClick={() => onRun(job.id)}
|
onClick={() => onRun(job.id)}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Edit Job">
|
<Tooltip content={t('jobs.tableEditJob')}>
|
||||||
<Button
|
<Button
|
||||||
type="secondary"
|
type="secondary"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -92,7 +95,7 @@ const JobsTable = ({ jobs, onRun, onEdit, onClone, onDeleteListings, onDeleteJob
|
|||||||
onClick={() => onEdit(job.id)}
|
onClick={() => onEdit(job.id)}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Clone Job">
|
<Tooltip content={t('jobs.tableCloneJob')}>
|
||||||
<Button
|
<Button
|
||||||
type="tertiary"
|
type="tertiary"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -101,7 +104,7 @@ const JobsTable = ({ jobs, onRun, onEdit, onClone, onDeleteListings, onDeleteJob
|
|||||||
onClick={() => onClone(job.id)}
|
onClick={() => onClone(job.id)}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Delete all found Listings">
|
<Tooltip content={t('jobs.tableDeleteListings')}>
|
||||||
<Button
|
<Button
|
||||||
type="danger"
|
type="danger"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -110,7 +113,7 @@ const JobsTable = ({ jobs, onRun, onEdit, onClone, onDeleteListings, onDeleteJob
|
|||||||
onClick={() => onDeleteListings(job.id)}
|
onClick={() => onDeleteListings(job.id)}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Delete Job">
|
<Tooltip content={t('jobs.tableDeleteJob')}>
|
||||||
<Button
|
<Button
|
||||||
type="danger"
|
type="danger"
|
||||||
size="small"
|
size="small"
|
||||||
@@ -123,6 +126,7 @@ const JobsTable = ({ jobs, onRun, onEdit, onClone, onDeleteListings, onDeleteJob
|
|||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default JobsTable;
|
export default JobsTable;
|
||||||
|
|||||||
@@ -19,11 +19,23 @@ import * as timeService from '../../services/time/timeService.js';
|
|||||||
import StatusControl from '../listings/StatusControl.jsx';
|
import StatusControl from '../listings/StatusControl.jsx';
|
||||||
|
|
||||||
import './ListingsTable.less';
|
import './ListingsTable.less';
|
||||||
|
import { useTranslation, useLocale } from '../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onStatusChange: Function }} props
|
* @param {{ listings: object[], onWatch: Function, onNavigate: Function, onDelete: Function, onRestore?: Function, isHiddenView?: boolean, onStatusChange: Function }} props
|
||||||
*/
|
*/
|
||||||
const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange }) => (
|
const ListingsTable = ({
|
||||||
|
listings,
|
||||||
|
onWatch,
|
||||||
|
onNavigate,
|
||||||
|
onDelete,
|
||||||
|
onRestore,
|
||||||
|
isHiddenView = false,
|
||||||
|
onStatusChange,
|
||||||
|
}) => {
|
||||||
|
const t = useTranslation();
|
||||||
|
const locale = useLocale();
|
||||||
|
return (
|
||||||
<div className="listingsTable">
|
<div className="listingsTable">
|
||||||
{listings.map((item) => (
|
{listings.map((item) => (
|
||||||
<div
|
<div
|
||||||
@@ -51,11 +63,7 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="listingsTable__row__price">
|
<div className="listingsTable__row__price">
|
||||||
{item.price ? (
|
{item.price ? formatEuroPrice(item.price) : <span className="listingsTable__row__empty">---</span>}
|
||||||
formatEuroPrice(item.price)
|
|
||||||
) : (
|
|
||||||
<span className="listingsTable__row__empty">---</span>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="listingsTable__row__address">
|
<div className="listingsTable__row__address">
|
||||||
@@ -74,7 +82,7 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
|
|||||||
{item.provider}
|
{item.provider}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="listingsTable__row__date">{timeService.format(item.created_at, false)}</div>
|
<div className="listingsTable__row__date">{timeService.format(item.created_at, false, locale)}</div>
|
||||||
|
|
||||||
<div className="listingsTable__row__actions" onClick={(e) => e.stopPropagation()}>
|
<div className="listingsTable__row__actions" onClick={(e) => e.stopPropagation()}>
|
||||||
<StatusControl
|
<StatusControl
|
||||||
@@ -83,17 +91,23 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
|
|||||||
onChange={(next) => onStatusChange?.(item, next)}
|
onChange={(next) => onStatusChange?.(item, next)}
|
||||||
onTriggerClick={(e) => e.stopPropagation()}
|
onTriggerClick={(e) => e.stopPropagation()}
|
||||||
/>
|
/>
|
||||||
<Tooltip content={item.isWatched === 1 ? 'Remove from Watchlist' : 'Add to Watchlist'}>
|
<Tooltip
|
||||||
|
content={
|
||||||
|
item.isWatched === 1 ? t('listings.tooltipRemoveFromWatchlist') : t('listings.tooltipAddToWatchlist')
|
||||||
|
}
|
||||||
|
>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="listingsTable__row__star"
|
className="listingsTable__row__star"
|
||||||
onClick={(e) => onWatch(e, item)}
|
onClick={(e) => onWatch(e, item)}
|
||||||
aria-label={item.isWatched === 1 ? 'Remove from watchlist' : 'Add to watchlist'}
|
aria-label={
|
||||||
|
item.isWatched === 1 ? t('listings.tooltipRemoveFromWatchlist') : t('listings.tooltipAddToWatchlist')
|
||||||
|
}
|
||||||
>
|
>
|
||||||
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
|
{item.isWatched === 1 ? <IconStar /> : <IconStarStroked />}
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Original Listing">
|
<Tooltip content={t('listings.tooltipOriginalListing')}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<IconLink />}
|
icon={<IconLink />}
|
||||||
@@ -105,7 +119,7 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="View in Fredy">
|
<Tooltip content={t('listings.tooltipViewInFredy')}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<IconEyeOpened />}
|
icon={<IconEyeOpened />}
|
||||||
@@ -117,7 +131,26 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content="Remove">
|
{isHiddenView ? (
|
||||||
|
<Tooltip content={t('listings.tooltipUndelete')}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={
|
||||||
|
<span className="listingsTable__strike" aria-hidden="true">
|
||||||
|
<IconDelete />
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
style={{ color: '#34d399' }}
|
||||||
|
theme="borderless"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRestore?.(item.id);
|
||||||
|
}}
|
||||||
|
aria-label={t('listings.tooltipUndelete')}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Tooltip content={t('listings.tooltipRemove')}>
|
||||||
<Button
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
icon={<IconDelete />}
|
icon={<IconDelete />}
|
||||||
@@ -129,10 +162,12 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export default ListingsTable;
|
export default ListingsTable;
|
||||||
|
|||||||
@@ -5,6 +5,25 @@
|
|||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 4px;
|
gap: 4px;
|
||||||
|
|
||||||
|
&__strike {
|
||||||
|
position: relative;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
left: -2px;
|
||||||
|
right: -2px;
|
||||||
|
top: 50%;
|
||||||
|
height: 2px;
|
||||||
|
background: currentColor;
|
||||||
|
transform: rotate(-45deg);
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&__row {
|
&__row {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 56px 1fr 140px 200px 120px 110px auto;
|
grid-template-columns: 56px 1fr 140px 200px 120px 110px auto;
|
||||||
|
|||||||
@@ -5,15 +5,17 @@
|
|||||||
|
|
||||||
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
|
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
|
||||||
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||||
|
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
export default function NotificationAdapterTable({ notificationAdapter = [], onRemove, onEdit } = {}) {
|
export default function NotificationAdapterTable({ notificationAdapter = [], onRemove, onEdit } = {}) {
|
||||||
|
const t = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Table
|
<Table
|
||||||
pagination={false}
|
pagination={false}
|
||||||
empty={<Empty description="No notification adapters found." />}
|
empty={<Empty description={t('notification.tableEmptyState')} />}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: 'Name',
|
title: t('notification.tableColumnName'),
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -6,23 +6,25 @@
|
|||||||
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
|
import { Empty, Table, Button } from '@douyinfe/semi-ui-19';
|
||||||
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||||
import { Typography } from '@douyinfe/semi-ui';
|
import { Typography } from '@douyinfe/semi-ui';
|
||||||
|
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
export default function ProviderTable({ providerData = [], onRemove, onEdit } = {}) {
|
export default function ProviderTable({ providerData = [], onRemove, onEdit } = {}) {
|
||||||
|
const t = useTranslation();
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
return (
|
return (
|
||||||
<Table
|
<Table
|
||||||
pagination={false}
|
pagination={false}
|
||||||
empty={<Empty description="No providers found." />}
|
empty={<Empty description={t('provider.tableEmptyState')} />}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: 'Name',
|
title: t('provider.tableColumnName'),
|
||||||
dataIndex: 'name',
|
dataIndex: 'name',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'URL',
|
title: t('provider.tableColumnUrl'),
|
||||||
dataIndex: 'url',
|
dataIndex: 'url',
|
||||||
render: (_, data) => {
|
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>;
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,19 +7,25 @@ import { IllustrationNoResult, IllustrationNoResultDark } from '@douyinfe/semi-i
|
|||||||
import { format } from '../../services/time/timeService';
|
import { format } from '../../services/time/timeService';
|
||||||
import { Table, Button, Empty, Tag } from '@douyinfe/semi-ui-19';
|
import { Table, Button, Empty, Tag } from '@douyinfe/semi-ui-19';
|
||||||
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
import { IconDelete, IconEdit } from '@douyinfe/semi-icons';
|
||||||
|
import { useTranslation, useLocale } from '../../services/i18n/i18n.jsx';
|
||||||
const empty = (
|
|
||||||
<Empty image={<IllustrationNoResult />} darkModeImage={<IllustrationNoResultDark />} description="No users found." />
|
|
||||||
);
|
|
||||||
|
|
||||||
export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {}) {
|
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 (
|
return (
|
||||||
<Table
|
<Table
|
||||||
pagination={false}
|
pagination={false}
|
||||||
empty={empty}
|
empty={empty}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
title: 'User',
|
title: t('users.tableColumnUser'),
|
||||||
dataIndex: 'username',
|
dataIndex: 'username',
|
||||||
render: (value, record) => (
|
render: (value, record) => (
|
||||||
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
|
||||||
@@ -38,23 +44,23 @@ export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {})
|
|||||||
padding: '0 8px',
|
padding: '0 8px',
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
ADMIN
|
{t('users.tableAdminBadge')}
|
||||||
</Tag>
|
</Tag>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Last login',
|
title: t('users.tableColumnLastLogin'),
|
||||||
dataIndex: 'lastLogin',
|
dataIndex: 'lastLogin',
|
||||||
render: (value) => (value == null ? '---' : format(value)),
|
render: (value) => (value == null ? '---' : format(value, true, locale)),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'Jobs',
|
title: t('users.tableColumnJobs'),
|
||||||
dataIndex: 'numberOfJobs',
|
dataIndex: 'numberOfJobs',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: 'MCP Token',
|
title: t('users.tableColumnMcpToken'),
|
||||||
dataIndex: 'mcpToken',
|
dataIndex: 'mcpToken',
|
||||||
render: (value) => (
|
render: (value) => (
|
||||||
<span
|
<span
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { xhrPost } from '../../services/xhr.js';
|
|||||||
|
|
||||||
import './TrackingModal.less';
|
import './TrackingModal.less';
|
||||||
import inDevelopment from '../../services/developmentMode.js';
|
import inDevelopment from '../../services/developmentMode.js';
|
||||||
|
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
const saveResponse = async (analyticsEnabled) => {
|
const saveResponse = async (analyticsEnabled) => {
|
||||||
await xhrPost('/api/admin/generalSettings', {
|
await xhrPost('/api/admin/generalSettings', {
|
||||||
@@ -17,6 +18,8 @@ const saveResponse = async (analyticsEnabled) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function TrackingModal() {
|
export default function TrackingModal() {
|
||||||
|
const t = useTranslation();
|
||||||
|
|
||||||
if (inDevelopment()) {
|
if (inDevelopment()) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -34,27 +37,17 @@ export default function TrackingModal() {
|
|||||||
}}
|
}}
|
||||||
maskClosable={false}
|
maskClosable={false}
|
||||||
closable={false}
|
closable={false}
|
||||||
okText="Yes! I want to help"
|
okText={t('tracking.okText')}
|
||||||
cancelText="No, thanks"
|
cancelText={t('tracking.cancelText')}
|
||||||
>
|
>
|
||||||
<Logo white />
|
<Logo white />
|
||||||
<div className="trackingModal__description">
|
<div className="trackingModal__description">
|
||||||
<p>Hey 👋</p>
|
<p>{t('tracking.greeting')}</p>
|
||||||
<p>Fed up with popups? Yeah, me too. But this one’s important, and I promise it will only appear once ;)</p>
|
<p>{t('tracking.paragraph1')}</p>
|
||||||
<p>
|
<p>{t('tracking.paragraph2')}</p>
|
||||||
Fredy is completely free (and will always remain free). If you’d like, you can support me by donating through
|
<p>{t('tracking.paragraph3')}</p>
|
||||||
my GitHub, but there’s absolutely no obligation to do so.
|
<p>{t('tracking.paragraph4')}</p>
|
||||||
</p>
|
<p>{t('tracking.thanks')}</p>
|
||||||
<p>
|
|
||||||
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)
|
|
||||||
</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>
|
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import { IconAlertCircle, IconArrowRight } from '@douyinfe/semi-icons';
|
|||||||
import { useSelector } from '../../services/state/store.js';
|
import { useSelector } from '../../services/state/store.js';
|
||||||
|
|
||||||
import './VersionBanner.less';
|
import './VersionBanner.less';
|
||||||
|
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
export default function VersionBanner() {
|
export default function VersionBanner() {
|
||||||
|
const t = useTranslation();
|
||||||
const [modalVisible, setModalVisible] = useState(false);
|
const [modalVisible, setModalVisible] = useState(false);
|
||||||
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
|
const versionUpdate = useSelector((state) => state.versionUpdate.versionUpdate);
|
||||||
|
|
||||||
@@ -28,13 +30,13 @@ export default function VersionBanner() {
|
|||||||
<Space spacing={8} align="center">
|
<Space spacing={8} align="center">
|
||||||
<IconAlertCircle size="small" />
|
<IconAlertCircle size="small" />
|
||||||
<Text strong size="small">
|
<Text strong size="small">
|
||||||
New version available
|
{t('version.newVersionAvailable')}
|
||||||
</Text>
|
</Text>
|
||||||
<Tag color="amber" size="small" shape="circle">
|
<Tag color="amber" size="small" shape="circle">
|
||||||
{versionUpdate.version}
|
{versionUpdate.version}
|
||||||
</Tag>
|
</Tag>
|
||||||
<Text type="tertiary" size="small">
|
<Text type="tertiary" size="small">
|
||||||
Current: {versionUpdate.localFredyVersion}
|
{t('version.currentLabel', { version: versionUpdate.localFredyVersion })}
|
||||||
</Text>
|
</Text>
|
||||||
</Space>
|
</Space>
|
||||||
<Button
|
<Button
|
||||||
@@ -44,7 +46,7 @@ export default function VersionBanner() {
|
|||||||
iconPosition="right"
|
iconPosition="right"
|
||||||
onClick={() => setModalVisible(true)}
|
onClick={() => setModalVisible(true)}
|
||||||
>
|
>
|
||||||
Release notes
|
{t('version.releaseNotes')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -54,7 +56,7 @@ export default function VersionBanner() {
|
|||||||
<Space spacing={8} align="center">
|
<Space spacing={8} align="center">
|
||||||
<Text strong>Fredy {versionUpdate.version}</Text>
|
<Text strong>Fredy {versionUpdate.version}</Text>
|
||||||
<Tag color="amber" size="small">
|
<Tag color="amber" size="small">
|
||||||
New
|
{t('version.newBadge')}
|
||||||
</Tag>
|
</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
@@ -63,21 +65,21 @@ export default function VersionBanner() {
|
|||||||
width={640}
|
width={640}
|
||||||
footer={
|
footer={
|
||||||
<Space>
|
<Space>
|
||||||
<Button onClick={() => setModalVisible(false)}>Close</Button>
|
<Button onClick={() => setModalVisible(false)}>{t('version.modalClose')}</Button>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
icon={<IconArrowRight />}
|
icon={<IconArrowRight />}
|
||||||
iconPosition="right"
|
iconPosition="right"
|
||||||
onClick={() => window.open(versionUpdate.url, '_blank')}
|
onClick={() => window.open(versionUpdate.url, '_blank')}
|
||||||
>
|
>
|
||||||
View on GitHub
|
{t('version.viewOnGithub')}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Descriptions row size="small" className="versionBanner__details">
|
<Descriptions row size="small" className="versionBanner__details">
|
||||||
<Descriptions.Item itemKey="Your Version">{versionUpdate.localFredyVersion}</Descriptions.Item>
|
<Descriptions.Item itemKey={t('version.yourVersion')}>{versionUpdate.localFredyVersion}</Descriptions.Item>
|
||||||
<Descriptions.Item itemKey="Latest Version">{versionUpdate.version}</Descriptions.Item>
|
<Descriptions.Item itemKey={t('version.latestVersion')}>{versionUpdate.version}</Descriptions.Item>
|
||||||
</Descriptions>
|
</Descriptions>
|
||||||
<div className="versionBanner__notes">
|
<div className="versionBanner__notes">
|
||||||
<MarkdownRender raw={versionUpdate.body} />
|
<MarkdownRender raw={versionUpdate.body} />
|
||||||
|
|||||||
504
ui/src/locales/de.json
Normal file
504
ui/src/locales/de.json
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
{
|
||||||
|
"_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.filterHidden": "Versteckt",
|
||||||
|
"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.filterSearchHelp": "Volltextsuche über Titel, Adresse, Anbieter und Link.",
|
||||||
|
"listings.filterActivityHelp": "Filtert nach Inseratsstatus: 'Alle' zeigt jedes Inserat, 'Aktiv' nur noch online verfügbare, 'Inaktiv' beim Anbieter verschwundene, 'Versteckt' zeigt deine manuell gelöschten (soft-deleted) Inserate, damit du sie wiederherstellen kannst.",
|
||||||
|
"listings.filterWatchHelp": "Filtert nach Watchlist-Zugehörigkeit: 'Alle' zeigt jedes Inserat, 'Beobachtet' nur die auf deiner Watchlist gespeicherten, 'Nicht beobachtet' die anderen.",
|
||||||
|
"listings.filterStatusHelp": "Filtert nach dem persönlichen Status (Beworben, Abgelehnt, Angenommen) oder zeigt nur Inserate ohne Status.",
|
||||||
|
"listings.filterProviderHelp": "Zeigt nur Inserate des ausgewählten Anbieters (ImmoScout24, Kleinanzeigen, ...).",
|
||||||
|
"listings.filterJobHelp": "Zeigt nur Inserate des ausgewählten Jobs.",
|
||||||
|
"listings.filterSortHelp": "Wählt das Sortierkriterium. Mit dem Pfeil-Button schaltet man zwischen aufsteigend und absteigend.",
|
||||||
|
"listings.hiddenViewBanner": "Du siehst gerade versteckte (soft-gelöschte) Inserate. Sie werden in den normalen Ansichten ausgeblendet. Über den Wiederherstellen-Button kannst du sie zurückholen.",
|
||||||
|
"listings.toastRestored": "Inserat wiederhergestellt",
|
||||||
|
"listings.toastRestoreError": "Wiederherstellung fehlgeschlagen",
|
||||||
|
"listings.tooltipUndelete": "Inserat wiederherstellen",
|
||||||
|
"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.tabDebug": "Debug",
|
||||||
|
"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.blacklistFilterOnProviderDetails": "Blacklist-Filter auf Anbieter-Details anwenden",
|
||||||
|
"settings.blacklistFilterOnProviderDetailsHelp": "Wenn aktiv, wird die Blacklist zusätzlich gegen die vollständige Beschreibung geprüft, die durch den obigen Anbieter-Details-Schritt geladen wurde. Damit lassen sich Spam-Anbieter (z. B. 'allkauf', 'massa') herausfiltern, die nur tief in der Detail-Seite auftauchen und nicht im kurzen Vorschau-Text der Suchergebnisse stehen. Standardmäßig aus, weil die vollständige Beschreibung oft generischen Boilerplate-Text (Kontaktdaten, rechtliche Hinweise) enthält, der ein Blacklist-Wort versehentlich auslösen und passende Inserate entfernen kann. Hat keine Wirkung auf Anbieter, für die Anbieter-Details nicht aktiviert sind.",
|
||||||
|
"settings.blacklistFilterOnProviderDetailsEnable": "Blacklist auf die vollständige Detail-Beschreibung anwenden",
|
||||||
|
"settings.blacklistFilterOnProviderDetailsUpdated": "Einstellung Blacklist-auf-Details aktualisiert.",
|
||||||
|
"settings.blacklistFilterOnProviderDetailsUpdateError": "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.",
|
||||||
|
"settings.debugSectionName": "Debug-Logging",
|
||||||
|
"settings.debugInfoTitle": "Was wird aufgezeichnet?",
|
||||||
|
"settings.debugInfoDescription": "Wenn aktiviert, schreibt Fredy jede Log-Zeile (debug, info, warn und error) in seine Datenbank. Maximal 5 MB werden gespeichert; sobald die Grenze erreicht ist, werden die ältesten Einträge automatisch gelöscht. Die Konsolen-Ausgabe bleibt unverändert.",
|
||||||
|
"settings.debugEnableButton": "Debug-Logging aktivieren",
|
||||||
|
"settings.debugDisableButton": "Debug-Logging deaktivieren",
|
||||||
|
"settings.debugDownloadButton": "Debug Informationen herunterladen",
|
||||||
|
"settings.debugUsedLabel": "Belegt:",
|
||||||
|
"settings.debugUsedValue": "{{used}} von {{max}} ({{percent}}%)",
|
||||||
|
"settings.debugStatusActive": "Debug-Logging ist aktiv!",
|
||||||
|
"settings.debugStatusInactive": "Debug-Logging ist inaktiv.",
|
||||||
|
"settings.debugConfirmReenableTitle": "Vorherige Logs löschen?",
|
||||||
|
"settings.debugConfirmReenableMessage": "Es sind noch Debug-Logs aus einer vorherigen Sitzung gespeichert. Möchtest du sie löschen, bevor das Debug-Logging erneut aktiviert wird?",
|
||||||
|
"settings.debugConfirmKeep": "Behalten & fortfahren",
|
||||||
|
"settings.debugConfirmDelete": "Löschen & aktivieren",
|
||||||
|
"settings.debugToastEnabled": "Debug-Logging aktiviert.",
|
||||||
|
"settings.debugToastDisabled": "Debug-Logging deaktiviert.",
|
||||||
|
"settings.debugToastEnableError": "Debug-Logging konnte nicht aktiviert werden.",
|
||||||
|
"settings.debugToastDisableError": "Debug-Logging konnte nicht deaktiviert werden.",
|
||||||
|
"settings.debugToastDownloadError": "Debug-Paket konnte nicht heruntergeladen werden.",
|
||||||
|
"settings.debugToastNoLogs": "Es sind noch keine Debug-Logs vorhanden. Aktiviere das Debug-Logging und reproduziere das Problem.",
|
||||||
|
"settings.debugClearButton": "Gespeicherte Debug-Logs löschen",
|
||||||
|
"settings.debugClearConfirmTitle": "Alle gespeicherten Debug-Logs löschen?",
|
||||||
|
"settings.debugClearConfirmMessage": "Damit werden alle gespeicherten Debug-Log-Einträge dauerhaft aus der Datenbank entfernt. Die Aufzeichnung selbst bleibt {{recordingState}}. Diese Aktion kann nicht rückgängig gemacht werden.",
|
||||||
|
"settings.debugClearConfirmRecordingOn": "Aktiv",
|
||||||
|
"settings.debugClearConfirmRecordingOff": "Inaktiv",
|
||||||
|
"settings.debugClearConfirmDelete": "Ja, Logs löschen",
|
||||||
|
"settings.debugClearConfirmCancel": "Abbrechen",
|
||||||
|
"settings.debugToastCleared": "Gespeicherte Debug-Logs wurden gelöscht.",
|
||||||
|
"settings.debugToastClearError": "Gespeicherte Debug-Logs konnten nicht gelöscht werden.",
|
||||||
|
"app.debugLoggingBanner": "Debug-Logging ist aktiv! Alles was Fredy loggt, wird in der Datenbank gespeichert. Deaktiviere es unter Einstellungen → Debug, sobald du fertig bist.",
|
||||||
|
|
||||||
|
"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."
|
||||||
|
}
|
||||||
504
ui/src/locales/en.json
Normal file
504
ui/src/locales/en.json
Normal file
@@ -0,0 +1,504 @@
|
|||||||
|
{
|
||||||
|
"_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.filterHidden": "Hidden",
|
||||||
|
"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.filterSearchHelp": "Free-text search across title, address, provider and link.",
|
||||||
|
"listings.filterActivityHelp": "Filter by listing activity: All shows every listing, Active only those still online, Inactive those that disappeared from the provider, Hidden shows your manually deleted (soft-deleted) listings so you can restore them.",
|
||||||
|
"listings.filterWatchHelp": "Filter by watchlist membership: All shows every listing, Watched only those you saved to your watchlist, Unwatched only those you have not saved.",
|
||||||
|
"listings.filterStatusHelp": "Filter by the personal status you set on a listing (Applied, Rejected, Accepted) or show only listings with no status yet.",
|
||||||
|
"listings.filterProviderHelp": "Show only listings coming from the selected real-estate provider (ImmoScout24, Kleinanzeigen, ...).",
|
||||||
|
"listings.filterJobHelp": "Show only listings produced by the selected job.",
|
||||||
|
"listings.filterSortHelp": "Choose the column to sort listings by. Use the arrow button to toggle ascending and descending order.",
|
||||||
|
"listings.hiddenViewBanner": "You are viewing hidden (soft-deleted) listings. They are excluded from the regular views. Use the restore button on a card to bring it back.",
|
||||||
|
"listings.toastRestored": "Listing restored",
|
||||||
|
"listings.toastRestoreError": "Failed to restore listing",
|
||||||
|
"listings.tooltipUndelete": "Restore Listing",
|
||||||
|
"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.tabDebug": "Debug",
|
||||||
|
"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.blacklistFilterOnProviderDetails": "Blacklist-Filtering on Provider Details",
|
||||||
|
"settings.blacklistFilterOnProviderDetailsHelp": "When enabled, the blacklist is re-checked against the full description loaded by the Provider Details step above. This catches spam advertisers (e.g. 'allkauf', 'massa') that only appear deep in the detail page and not in the short search-result snippet. Off by default, because the full description often contains generic boilerplate (contact info, legal text) that may accidentally trigger a blacklist term and remove otherwise relevant listings. Has no effect on providers for which Provider Details is not enabled.",
|
||||||
|
"settings.blacklistFilterOnProviderDetailsEnable": "Apply blacklist to the full detail description",
|
||||||
|
"settings.blacklistFilterOnProviderDetailsUpdated": "Blacklist-on-details setting updated.",
|
||||||
|
"settings.blacklistFilterOnProviderDetailsUpdateError": "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.",
|
||||||
|
"settings.debugSectionName": "Debug Logging",
|
||||||
|
"settings.debugInfoTitle": "What gets recorded?",
|
||||||
|
"settings.debugInfoDescription": "When enabled, Fredy records every log line (debug, info, warn and error) into its database. A maximum of 5 MB is kept; once the cap is reached, the oldest entries are dropped automatically. Console output is unaffected.",
|
||||||
|
"settings.debugEnableButton": "Enable debug logging",
|
||||||
|
"settings.debugDisableButton": "Disable debug logging",
|
||||||
|
"settings.debugDownloadButton": "Download debug information",
|
||||||
|
"settings.debugUsedLabel": "Used:",
|
||||||
|
"settings.debugUsedValue": "{{used}} of {{max}} ({{percent}}%)",
|
||||||
|
"settings.debugStatusActive": "Debug logging is currently active.",
|
||||||
|
"settings.debugStatusInactive": "Debug logging is currently inactive.",
|
||||||
|
"settings.debugConfirmReenableTitle": "Delete previous logs?",
|
||||||
|
"settings.debugConfirmReenableMessage": "Debug logs from a previous session are still stored. Do you want to delete them before enabling debug logging again?",
|
||||||
|
"settings.debugConfirmKeep": "Keep & continue",
|
||||||
|
"settings.debugConfirmDelete": "Delete & enable",
|
||||||
|
"settings.debugToastEnabled": "Debug logging enabled.",
|
||||||
|
"settings.debugToastDisabled": "Debug logging disabled.",
|
||||||
|
"settings.debugToastEnableError": "Could not enable debug logging.",
|
||||||
|
"settings.debugToastDisableError": "Could not disable debug logging.",
|
||||||
|
"settings.debugToastDownloadError": "Could not download the debug bundle.",
|
||||||
|
"settings.debugToastNoLogs": "No debug logs available yet. Enable debug logging first and reproduce the issue.",
|
||||||
|
"settings.debugClearButton": "Delete stored debug logs",
|
||||||
|
"settings.debugClearConfirmTitle": "Delete all stored debug logs?",
|
||||||
|
"settings.debugClearConfirmMessage": "This permanently removes every stored debug log entry from the database. Recording itself will stay {{recordingState}}. This action cannot be undone.",
|
||||||
|
"settings.debugClearConfirmRecordingOn": "ON",
|
||||||
|
"settings.debugClearConfirmRecordingOff": "OFF",
|
||||||
|
"settings.debugClearConfirmDelete": "Yes, delete logs",
|
||||||
|
"settings.debugClearConfirmCancel": "Cancel",
|
||||||
|
"settings.debugToastCleared": "Stored debug logs were deleted.",
|
||||||
|
"settings.debugToastClearError": "Could not delete the stored debug logs.",
|
||||||
|
"app.debugLoggingBanner": "Debug logging is active! Everything Fredy logs is being stored in its database. Disable it in Settings → Debug once you're done.",
|
||||||
|
|
||||||
|
"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."
|
||||||
|
}
|
||||||
124
ui/src/services/debugLoggingClient.js
Normal file
124
ui/src/services/debugLoggingClient.js
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tiny client wrapping the /api/admin/debug endpoints.
|
||||||
|
*
|
||||||
|
* The server returns the same status payload from every mutation endpoint so the UI
|
||||||
|
* does not need to re-fetch after enable/disable, it can apply the response payload
|
||||||
|
* directly.
|
||||||
|
*/
|
||||||
|
|
||||||
|
function extractFileNameFromDisposition(disposition) {
|
||||||
|
const dispo = disposition || '';
|
||||||
|
// RFC 6266 says the UTF-8 encoded `filename*=` form takes precedence over the
|
||||||
|
// legacy `filename=` form when both are present. Match each form independently
|
||||||
|
// and prefer the UTF-8 one so we cannot accidentally pick the wrong encoding.
|
||||||
|
const utf8Match = dispo.match(/filename\*=UTF-8''([^;]+)/);
|
||||||
|
if (utf8Match) {
|
||||||
|
try {
|
||||||
|
return decodeURIComponent(utf8Match[1]);
|
||||||
|
} catch {
|
||||||
|
// malformed percent-encoding; fall through to the legacy form
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const legacyMatch = dispo.match(/filename="?([^";]+)"?/);
|
||||||
|
if (legacyMatch) return legacyMatch[1];
|
||||||
|
return 'FredyDebug.zip';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch the current feature status. Requires admin auth.
|
||||||
|
* @returns {Promise<{enabled:boolean, size:number, max:number, hasLogs:boolean, everEnabled:boolean}>}
|
||||||
|
*/
|
||||||
|
export async function fetchDebugStatus() {
|
||||||
|
const resp = await fetch('/api/admin/debug/status', { credentials: 'include' });
|
||||||
|
if (!resp.ok) throw new Error('Failed to load debug logging status');
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lightweight "is debug logging active right now?" probe usable by any authenticated
|
||||||
|
* user. Used by the app-wide red banner so non-admin users also see the warning. The
|
||||||
|
* payload is intentionally a single boolean, no other settings are exposed.
|
||||||
|
*
|
||||||
|
* @returns {Promise<{enabled:boolean}>}
|
||||||
|
*/
|
||||||
|
export async function fetchDebugActive() {
|
||||||
|
const resp = await fetch('/api/debug/active', { credentials: 'include' });
|
||||||
|
if (!resp.ok) throw new Error('Failed to load debug active flag');
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enable the feature. When clearPrevious is true, existing log rows are dropped
|
||||||
|
* before the new collection starts.
|
||||||
|
* @param {{clearPrevious?:boolean}} [options]
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function enableDebugLogging({ clearPrevious = false } = {}) {
|
||||||
|
const resp = await fetch('/api/admin/debug/enable', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ clearPrevious }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error('Failed to enable debug logging');
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Disable the feature. Existing logs remain on disk so they can still be downloaded.
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function disableDebugLogging() {
|
||||||
|
const resp = await fetch('/api/admin/debug/disable', {
|
||||||
|
method: 'POST',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error('Failed to disable debug logging');
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Drop every stored debug log row. Does NOT change the enabled flag: if recording
|
||||||
|
* was on, it stays on and the table simply starts filling again. Returns the new
|
||||||
|
* status payload.
|
||||||
|
* @returns {Promise<object>}
|
||||||
|
*/
|
||||||
|
export async function clearDebugLogs() {
|
||||||
|
const resp = await fetch('/api/admin/debug/logs', {
|
||||||
|
method: 'DELETE',
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error('Failed to clear debug logs');
|
||||||
|
return resp.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Trigger the debug bundle download. Throws when there is nothing to export (server
|
||||||
|
* returns 409 in that case) or any other non-2xx response.
|
||||||
|
* @returns {Promise<void>}
|
||||||
|
*/
|
||||||
|
export async function downloadDebugBundle() {
|
||||||
|
const resp = await fetch('/api/admin/debug/download', { credentials: 'include' });
|
||||||
|
if (resp.status === 409) {
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
const err = new Error(data?.error || 'No debug logs available yet');
|
||||||
|
err.code = 'NO_LOGS';
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
if (!resp.ok) throw new Error('Failed to download debug bundle');
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const fileName = extractFileNameFromDisposition(resp.headers.get('Content-Disposition'));
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = fileName;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
a.remove();
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
}
|
||||||
136
ui/src/services/i18n/i18n.jsx
Normal file
136
ui/src/services/i18n/i18n.jsx
Normal 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 };
|
||||||
@@ -276,6 +276,14 @@ export const useFredyState = create(
|
|||||||
throw Exception;
|
throw Exception;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async restoreListings(ids) {
|
||||||
|
try {
|
||||||
|
await xhrPost('/api/listings/restore', { ids });
|
||||||
|
} catch (Exception) {
|
||||||
|
console.error('Error while trying to restore listings. Error:', Exception);
|
||||||
|
throw Exception;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
userSettings: {
|
userSettings: {
|
||||||
async getUserSettings() {
|
async getUserSettings() {
|
||||||
@@ -337,6 +345,28 @@ export const useFredyState = create(
|
|||||||
throw Exception;
|
throw Exception;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async setBlacklistFilterOnProviderDetails(enabled) {
|
||||||
|
try {
|
||||||
|
await xhrPost('/api/user/settings/blacklist-filter-on-details', {
|
||||||
|
blacklist_filter_on_provider_details: enabled,
|
||||||
|
});
|
||||||
|
set((state) => ({
|
||||||
|
userSettings: {
|
||||||
|
...state.userSettings,
|
||||||
|
settings: {
|
||||||
|
...state.userSettings.settings,
|
||||||
|
blacklist_filter_on_provider_details: enabled,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (Exception) {
|
||||||
|
console.error(
|
||||||
|
'Error while trying to update blacklist-filter-on-provider-details setting. Error:',
|
||||||
|
Exception,
|
||||||
|
);
|
||||||
|
throw Exception;
|
||||||
|
}
|
||||||
|
},
|
||||||
async setListingsViewMode(listings_view_mode) {
|
async setListingsViewMode(listings_view_mode) {
|
||||||
try {
|
try {
|
||||||
await xhrPost('/api/user/settings/listings-view-mode', { listings_view_mode });
|
await xhrPost('/api/user/settings/listings-view-mode', { listings_view_mode });
|
||||||
@@ -379,6 +409,20 @@ export const useFredyState = create(
|
|||||||
throw 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;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function format(ts, showSeconds = true) {
|
export function format(ts, showSeconds = true, locale = 'default') {
|
||||||
return new Intl.DateTimeFormat('default', {
|
return new Intl.DateTimeFormat(locale, {
|
||||||
year: 'numeric',
|
year: 'numeric',
|
||||||
month: 'numeric',
|
month: 'numeric',
|
||||||
day: 'numeric',
|
day: 'numeric',
|
||||||
|
|||||||
@@ -25,8 +25,11 @@ import Headline from '../../components/headline/Headline.jsx';
|
|||||||
import './Dashboard.less';
|
import './Dashboard.less';
|
||||||
import { xhrPost } from '../../services/xhr.js';
|
import { xhrPost } from '../../services/xhr.js';
|
||||||
import { format } from '../../services/time/timeService.js';
|
import { format } from '../../services/time/timeService.js';
|
||||||
|
import { useTranslation, useLocale } from '../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
export default function Dashboard() {
|
export default function Dashboard() {
|
||||||
|
const t = useTranslation();
|
||||||
|
const locale = useLocale();
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
const dashboard = useSelector((state) => state.dashboard.data);
|
const dashboard = useSelector((state) => state.dashboard.data);
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -38,111 +41,111 @@ export default function Dashboard() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="dashboard">
|
<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">
|
<Row gutter={[16, 16]} className="dashboard__row">
|
||||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title="Search Interval"
|
title={t('dashboard.searchInterval')}
|
||||||
value={`${dashboard?.general?.interval} min`}
|
value={`${dashboard?.general?.interval} min`}
|
||||||
icon={<IconClock />}
|
icon={<IconClock />}
|
||||||
description="Time interval for job execution"
|
description={t('dashboard.searchIntervalDesc')}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title="Last Search"
|
title={t('dashboard.lastSearch')}
|
||||||
value={
|
value={
|
||||||
dashboard?.general?.lastRun == null || dashboard?.general?.lastRun === 0
|
dashboard?.general?.lastRun == null || dashboard?.general?.lastRun === 0
|
||||||
? '---'
|
? '---'
|
||||||
: format(dashboard?.general?.lastRun)
|
: format(dashboard?.general?.lastRun, true, locale)
|
||||||
}
|
}
|
||||||
icon={<IconDoubleChevronLeft />}
|
icon={<IconDoubleChevronLeft />}
|
||||||
description="Last execution timestamp"
|
description={t('dashboard.lastSearchDesc')}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title="Next Search"
|
title={t('dashboard.nextSearch')}
|
||||||
value={
|
value={
|
||||||
dashboard?.general?.nextRun == null || dashboard?.general?.nextRun === 0
|
dashboard?.general?.nextRun == null || dashboard?.general?.nextRun === 0
|
||||||
? '---'
|
? '---'
|
||||||
: format(dashboard?.general?.nextRun)
|
: format(dashboard?.general?.nextRun, true, locale)
|
||||||
}
|
}
|
||||||
icon={<IconDoubleChevronRight />}
|
icon={<IconDoubleChevronRight />}
|
||||||
description="Next execution timestamp"
|
description={t('dashboard.nextSearchDesc')}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
<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
|
<Button
|
||||||
size="small"
|
size="small"
|
||||||
style={{ marginTop: '.2rem' }}
|
style={{ marginTop: '.2rem' }}
|
||||||
icon={<IconPlayCircle />}
|
icon={<IconPlayCircle />}
|
||||||
aria-label="Start now"
|
aria-label={t('common.startNow')}
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
try {
|
try {
|
||||||
await xhrPost('/api/jobs/startAll', null);
|
await xhrPost('/api/jobs/startAll', null);
|
||||||
Toast.success('Successfully triggered Fredy search.');
|
Toast.success(t('dashboard.searchNowStarted'));
|
||||||
} catch {
|
} catch {
|
||||||
Toast.error('Failed to trigger search');
|
Toast.error(t('dashboard.searchNowFailed'));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Search now
|
{t('dashboard.searchNowButton')}
|
||||||
</Button>
|
</Button>
|
||||||
</KpiCard>
|
</KpiCard>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<div className="dashboard__section-label">Overview</div>
|
<div className="dashboard__section-label">{t('dashboard.sectionOverview')}</div>
|
||||||
<Row gutter={[16, 16]} className="dashboard__row">
|
<Row gutter={[16, 16]} className="dashboard__row">
|
||||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title="Jobs"
|
title={t('dashboard.kpiJobs')}
|
||||||
color="blue"
|
color="blue"
|
||||||
value={!kpis.totalJobs ? '---' : kpis.totalJobs}
|
value={!kpis.totalJobs ? '---' : kpis.totalJobs}
|
||||||
icon={<IconTerminal />}
|
icon={<IconTerminal />}
|
||||||
description="Total number of jobs"
|
description={t('dashboard.kpiJobsDesc')}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title="Listings"
|
title={t('dashboard.kpiListings')}
|
||||||
color="orange"
|
color="orange"
|
||||||
value={!kpis.totalListings ? '---' : kpis.totalListings}
|
value={!kpis.totalListings ? '---' : kpis.totalListings}
|
||||||
icon={<IconStarStroked />}
|
icon={<IconStarStroked />}
|
||||||
description="Total listings found"
|
description={t('dashboard.kpiListingsDesc')}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title="Active Listings"
|
title={t('dashboard.kpiActiveListings')}
|
||||||
color="green"
|
color="green"
|
||||||
value={!kpis.numberOfActiveListings ? '---' : kpis.numberOfActiveListings}
|
value={!kpis.numberOfActiveListings ? '---' : kpis.numberOfActiveListings}
|
||||||
icon={<IconStar />}
|
icon={<IconStar />}
|
||||||
description="Total active listings"
|
description={t('dashboard.kpiActiveListingsDesc')}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||||
<KpiCard
|
<KpiCard
|
||||||
title="Median Price"
|
title={t('dashboard.kpiMedianPrice')}
|
||||||
color="purple"
|
color="purple"
|
||||||
value={`${
|
value={`${
|
||||||
!kpis.medianPriceOfListings
|
!kpis.medianPriceOfListings
|
||||||
? '---'
|
? '---'
|
||||||
: new Intl.NumberFormat('de-DE', { style: 'currency', currency: 'EUR' }).format(
|
: new Intl.NumberFormat(locale, { style: 'currency', currency: 'EUR' }).format(
|
||||||
kpis.medianPriceOfListings,
|
kpis.medianPriceOfListings,
|
||||||
)
|
)
|
||||||
}`}
|
}`}
|
||||||
icon={<IconNoteMoney />}
|
icon={<IconNoteMoney />}
|
||||||
description="Median Price of listings"
|
description={t('dashboard.kpiMedianPriceDesc')}
|
||||||
/>
|
/>
|
||||||
</Col>
|
</Col>
|
||||||
</Row>
|
</Row>
|
||||||
|
|
||||||
<div className="dashboard__section-label">Provider Insights</div>
|
<div className="dashboard__section-label">{t('dashboard.sectionProviderInsights')}</div>
|
||||||
<div className="dashboard__pie-wrapper">
|
<div className="dashboard__pie-wrapper">
|
||||||
<PieChartCard data={pieData} />
|
<PieChartCard data={pieData} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
import React, { useEffect, useState, useMemo } from 'react';
|
import React, { useEffect, useState, useMemo } from 'react';
|
||||||
|
|
||||||
import { useActions, useSelector, useIsLoading } from '../../services/state/store';
|
import { useActions, useSelector, useIsLoading } from '../../services/state/store';
|
||||||
|
import { useTranslation, availableLanguages } from '../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Tabs,
|
Tabs,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
Radio,
|
Radio,
|
||||||
RadioGroup,
|
RadioGroup,
|
||||||
Typography,
|
Typography,
|
||||||
|
Progress,
|
||||||
} from '@douyinfe/semi-ui-19';
|
} from '@douyinfe/semi-ui-19';
|
||||||
import { InputNumber } from '@douyinfe/semi-ui-19';
|
import { InputNumber } from '@douyinfe/semi-ui-19';
|
||||||
import { xhrPost, xhrGet } from '../../services/xhr';
|
import { xhrPost, xhrGet } from '../../services/xhr';
|
||||||
@@ -31,7 +33,14 @@ import {
|
|||||||
precheckRestore as clientPrecheckRestore,
|
precheckRestore as clientPrecheckRestore,
|
||||||
restore as clientRestore,
|
restore as clientRestore,
|
||||||
} from '../../services/backupRestoreClient';
|
} from '../../services/backupRestoreClient';
|
||||||
import { IconSave, IconRefresh, IconSignal, IconHome, IconFolder } from '@douyinfe/semi-icons';
|
import {
|
||||||
|
fetchDebugStatus,
|
||||||
|
enableDebugLogging as apiEnableDebugLogging,
|
||||||
|
disableDebugLogging as apiDisableDebugLogging,
|
||||||
|
downloadDebugBundle,
|
||||||
|
clearDebugLogs as apiClearDebugLogs,
|
||||||
|
} from '../../services/debugLoggingClient';
|
||||||
|
import { IconSave, IconRefresh, IconSignal, IconHome, IconFolder, IconAlertTriangle } from '@douyinfe/semi-icons';
|
||||||
import { debounce } from '../../utils';
|
import { debounce } from '../../utils';
|
||||||
import Headline from '../../components/headline/Headline.jsx';
|
import Headline from '../../components/headline/Headline.jsx';
|
||||||
import './GeneralSettings.less';
|
import './GeneralSettings.less';
|
||||||
@@ -54,12 +63,40 @@ function formatFromTBackend(time) {
|
|||||||
return date.getTime();
|
return date.getTime();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Human-readable byte formatter used by the Debug tab's usage label.
|
||||||
|
* @param {number} bytes
|
||||||
|
* @returns {string}
|
||||||
|
*/
|
||||||
|
function formatBytes(bytes) {
|
||||||
|
if (!Number.isFinite(bytes)) return String(bytes);
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(2)} MiB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Compute the integer percentage that `used` represents of `total`, clamped to [0, 100].
|
||||||
|
* @param {number} used
|
||||||
|
* @param {number} total
|
||||||
|
* @returns {number}
|
||||||
|
*/
|
||||||
|
function percentOf(used, total) {
|
||||||
|
if (!total || total <= 0) return 0;
|
||||||
|
const pct = Math.round((used / total) * 100);
|
||||||
|
if (pct < 0) return 0;
|
||||||
|
if (pct > 100) return 100;
|
||||||
|
return pct;
|
||||||
|
}
|
||||||
|
|
||||||
const GeneralSettings = function GeneralSettings() {
|
const GeneralSettings = function GeneralSettings() {
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
|
const t = useTranslation();
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
|
|
||||||
const settings = useSelector((state) => state.generalSettings.settings);
|
const settings = useSelector((state) => state.generalSettings.settings);
|
||||||
const currentUser = useSelector((state) => state.user.currentUser);
|
const currentUser = useSelector((state) => state.user.currentUser);
|
||||||
|
const language = useSelector((state) => state.userSettings.settings.language);
|
||||||
|
|
||||||
const [interval, setInterval] = React.useState('');
|
const [interval, setInterval] = React.useState('');
|
||||||
const [proxyUrl, setProxyUrl] = React.useState('');
|
const [proxyUrl, setProxyUrl] = React.useState('');
|
||||||
@@ -76,9 +113,26 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
const [restoreBusy, setRestoreBusy] = React.useState(false);
|
const [restoreBusy, setRestoreBusy] = React.useState(false);
|
||||||
const [selectedRestoreFile, setSelectedRestoreFile] = React.useState(null);
|
const [selectedRestoreFile, setSelectedRestoreFile] = React.useState(null);
|
||||||
|
|
||||||
|
// Debug-logging tab state. status is fetched on mount + polled every 3s while the
|
||||||
|
// feature is active so the progress bar reflects the live byte budget.
|
||||||
|
// debugStatusSeq monotonically increases with every applied status update so we can
|
||||||
|
// discard stale polling responses that arrive after a manual enable/disable.
|
||||||
|
const [debugStatus, setDebugStatus] = React.useState(null);
|
||||||
|
const [debugBusy, setDebugBusy] = React.useState(false);
|
||||||
|
const [debugConfirmVisible, setDebugConfirmVisible] = React.useState(false);
|
||||||
|
const [debugClearConfirmVisible, setDebugClearConfirmVisible] = React.useState(false);
|
||||||
|
const debugStatusSeqRef = React.useRef(0);
|
||||||
|
const applyDebugStatus = React.useCallback((fresh) => {
|
||||||
|
debugStatusSeqRef.current += 1;
|
||||||
|
setDebugStatus(fresh);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// User settings state
|
// User settings state
|
||||||
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
||||||
const providerDetails = useSelector((state) => state.userSettings.settings.provider_details);
|
const providerDetails = useSelector((state) => state.userSettings.settings.provider_details);
|
||||||
|
const blacklistFilterOnProviderDetails = useSelector(
|
||||||
|
(state) => state.userSettings.settings.blacklist_filter_on_provider_details,
|
||||||
|
);
|
||||||
const listingDeletionPreference = useSelector((state) => state.userSettings.settings.listing_deletion_preference);
|
const listingDeletionPreference = useSelector((state) => state.userSettings.settings.listing_deletion_preference);
|
||||||
const allProviders = useSelector((state) => state.provider);
|
const allProviders = useSelector((state) => state.provider);
|
||||||
const [address, setAddress] = useState(homeAddress?.address || '');
|
const [address, setAddress] = useState(homeAddress?.address || '');
|
||||||
@@ -86,6 +140,7 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
const [listingDeleteHard, setListingDeleteHard] = useState(false);
|
const [listingDeleteHard, setListingDeleteHard] = useState(false);
|
||||||
const [listingDeleteSkipPrompt, setListingDeleteSkipPrompt] = useState(false);
|
const [listingDeleteSkipPrompt, setListingDeleteSkipPrompt] = useState(false);
|
||||||
const saving = useIsLoading(actions.userSettings.setHomeAddress);
|
const saving = useIsLoading(actions.userSettings.setHomeAddress);
|
||||||
|
const savingLanguage = useIsLoading(actions.userSettings.setLanguage);
|
||||||
const [dataSource, setDataSource] = useState([]);
|
const [dataSource, setDataSource] = useState([]);
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
@@ -123,26 +178,65 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
setListingDeleteSkipPrompt(listingDeletionPreference?.skipPrompt ?? false);
|
setListingDeleteSkipPrompt(listingDeletionPreference?.skipPrompt ?? false);
|
||||||
}, [listingDeletionPreference]);
|
}, [listingDeletionPreference]);
|
||||||
|
|
||||||
|
// Initial debug-status load. Subsequent updates flow through applyDebugStatus()
|
||||||
|
// (called by polling + after every enable/disable action), so this effect only
|
||||||
|
// needs to fire once on mount.
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
fetchDebugStatus()
|
||||||
|
.then((s) => {
|
||||||
|
if (!cancelled) applyDebugStatus(s);
|
||||||
|
})
|
||||||
|
.catch((e) => {
|
||||||
|
// Non-fatal: tab is still usable, polling will retry.
|
||||||
|
console.error('Failed to load debug status', e);
|
||||||
|
});
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [applyDebugStatus]);
|
||||||
|
|
||||||
|
// Live polling while the feature is active so the progress bar reflects new entries
|
||||||
|
// as they are written. We intentionally do NOT poll while inactive — the size stays
|
||||||
|
// constant and there's no Banner to update. Stale poll responses (where a manual
|
||||||
|
// enable/disable bumped the sequence in the meantime) are discarded so the UI does
|
||||||
|
// not flicker back to the previous state for ~3s.
|
||||||
|
useEffect(() => {
|
||||||
|
if (!debugStatus?.enabled) return undefined;
|
||||||
|
const id = window.setInterval(async () => {
|
||||||
|
const seqAtStart = debugStatusSeqRef.current;
|
||||||
|
try {
|
||||||
|
const fresh = await fetchDebugStatus();
|
||||||
|
if (debugStatusSeqRef.current === seqAtStart) {
|
||||||
|
applyDebugStatus(fresh);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore transient errors; next tick will retry
|
||||||
|
}
|
||||||
|
}, 3000);
|
||||||
|
return () => window.clearInterval(id);
|
||||||
|
}, [debugStatus?.enabled, applyDebugStatus]);
|
||||||
|
|
||||||
const nullOrEmpty = (val) => val == null || val.length === 0;
|
const nullOrEmpty = (val) => val == null || val.length === 0;
|
||||||
|
|
||||||
const handleStore = async () => {
|
const handleStore = async () => {
|
||||||
if (nullOrEmpty(interval)) {
|
if (nullOrEmpty(interval)) {
|
||||||
Toast.error('Interval may not be empty.');
|
Toast.error(t('settings.toastIntervalEmpty'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (nullOrEmpty(port)) {
|
if (nullOrEmpty(port)) {
|
||||||
Toast.error('Port may not be empty.');
|
Toast.error(t('settings.toastPortEmpty'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
(!nullOrEmpty(workingHourFrom) && nullOrEmpty(workingHourTo)) ||
|
(!nullOrEmpty(workingHourFrom) && nullOrEmpty(workingHourTo)) ||
|
||||||
(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;
|
return;
|
||||||
}
|
}
|
||||||
if (nullOrEmpty(sqlitePath)) {
|
if (nullOrEmpty(sqlitePath)) {
|
||||||
Toast.error('SQLite db path cannot be empty.');
|
Toast.error(t('settings.toastSqlitePathEmpty'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -161,14 +255,14 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
});
|
});
|
||||||
} catch (exception) {
|
} catch (exception) {
|
||||||
console.error(exception);
|
console.error(exception);
|
||||||
if (exception?.json?.message != null) {
|
// The backend returns the concrete reason in `json.error` (e.g. a 403
|
||||||
Toast.error(exception.json.message);
|
// "Only admins can change these settings."). Fall back to `json.message`
|
||||||
} else {
|
// and finally the generic toast so the user always sees why it failed.
|
||||||
Toast.error('Error while trying to store settings.');
|
const serverReason = exception?.json?.error ?? exception?.json?.message;
|
||||||
}
|
Toast.error(serverReason ?? t('settings.toastSaveError'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Toast.success('Settings stored successfully. We will reload your browser in 3 seconds.');
|
Toast.success(t('settings.toastSavedReloading'));
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
location.reload();
|
location.reload();
|
||||||
}, 3000);
|
}, 3000);
|
||||||
@@ -179,7 +273,7 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
await downloadBackupZip();
|
await downloadBackupZip();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
Toast.error('Unexpected error while downloading backup.');
|
Toast.error(t('settings.backupDownloadError'));
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -190,7 +284,7 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
setRestoreModalVisible(true);
|
setRestoreModalVisible(true);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
Toast.error('Failed to analyze backup.');
|
Toast.error(t('settings.backupAnalyzeError'));
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
@@ -199,10 +293,10 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
try {
|
try {
|
||||||
setRestoreBusy(true);
|
setRestoreBusy(true);
|
||||||
await clientRestore(selectedRestoreFile, force);
|
await clientRestore(selectedRestoreFile, force);
|
||||||
Toast.success('Restore completed. Please restart the Fredy backend now!');
|
Toast.success(t('settings.backupRestoreCompleted'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
Toast.error(e?.message || 'Unexpected error while restoring backup.');
|
Toast.error(e?.message || t('settings.backupRestoreError'));
|
||||||
} finally {
|
} finally {
|
||||||
setRestoreBusy(false);
|
setRestoreBusy(false);
|
||||||
}
|
}
|
||||||
@@ -227,6 +321,89 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// ── Debug-logging actions ────────────────────────────────────────────────────
|
||||||
|
// performEnableDebug() centralizes the actual enable call so both branches of the
|
||||||
|
// confirm dialog ("delete" vs. "keep") plus the no-confirm fast-path can share it.
|
||||||
|
const performEnableDebug = React.useCallback(
|
||||||
|
async ({ clearPrevious }) => {
|
||||||
|
setDebugBusy(true);
|
||||||
|
try {
|
||||||
|
const fresh = await apiEnableDebugLogging({ clearPrevious });
|
||||||
|
applyDebugStatus(fresh);
|
||||||
|
// Keep the global generalSettings store in sync so the app-wide red banner
|
||||||
|
// (which reads settings.debug_logging_enabled) updates immediately.
|
||||||
|
await actions.generalSettings.getGeneralSettings();
|
||||||
|
Toast.success(t('settings.debugToastEnabled'));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
Toast.error(t('settings.debugToastEnableError'));
|
||||||
|
} finally {
|
||||||
|
setDebugBusy(false);
|
||||||
|
setDebugConfirmVisible(false);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[actions.generalSettings, applyDebugStatus, t],
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleToggleDebugLogging = React.useCallback(async () => {
|
||||||
|
// Guard against the initial-load race: if status hasn't arrived yet, ignore the
|
||||||
|
// click. The button is also disabled when debugStatus == null, this is belt &
|
||||||
|
// braces for the case where the click somehow reached the handler anyway.
|
||||||
|
if (debugStatus == null) return;
|
||||||
|
if (debugStatus.enabled) {
|
||||||
|
setDebugBusy(true);
|
||||||
|
try {
|
||||||
|
const fresh = await apiDisableDebugLogging();
|
||||||
|
applyDebugStatus(fresh);
|
||||||
|
await actions.generalSettings.getGeneralSettings();
|
||||||
|
Toast.success(t('settings.debugToastDisabled'));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
Toast.error(t('settings.debugToastDisableError'));
|
||||||
|
} finally {
|
||||||
|
setDebugBusy(false);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Enabling: if logs from a previous session are still around, ask first.
|
||||||
|
if (debugStatus.hasLogs) {
|
||||||
|
setDebugConfirmVisible(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await performEnableDebug({ clearPrevious: false });
|
||||||
|
}, [debugStatus, performEnableDebug, actions.generalSettings, applyDebugStatus, t]);
|
||||||
|
|
||||||
|
const handleDownloadDebugBundle = React.useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await downloadDebugBundle();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
if (e?.code === 'NO_LOGS') {
|
||||||
|
Toast.error(t('settings.debugToastNoLogs'));
|
||||||
|
} else {
|
||||||
|
Toast.error(t('settings.debugToastDownloadError'));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [t]);
|
||||||
|
|
||||||
|
// Deleting stored logs is a separate action from disabling the feature: the user can
|
||||||
|
// free up the rolling buffer mid-recording without turning off collection. The
|
||||||
|
// confirmation dialog makes the destructive nature explicit.
|
||||||
|
const performClearDebugLogs = React.useCallback(async () => {
|
||||||
|
setDebugBusy(true);
|
||||||
|
try {
|
||||||
|
const fresh = await apiClearDebugLogs();
|
||||||
|
applyDebugStatus(fresh);
|
||||||
|
Toast.success(t('settings.debugToastCleared'));
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
Toast.error(t('settings.debugToastClearError'));
|
||||||
|
} finally {
|
||||||
|
setDebugBusy(false);
|
||||||
|
setDebugClearConfirmVisible(false);
|
||||||
|
}
|
||||||
|
}, [applyDebugStatus, t]);
|
||||||
|
|
||||||
const handleSaveUserSettings = async () => {
|
const handleSaveUserSettings = async () => {
|
||||||
try {
|
try {
|
||||||
const responseJson = await actions.userSettings.setHomeAddress(address);
|
const responseJson = await actions.userSettings.setHomeAddress(address);
|
||||||
@@ -236,9 +413,9 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
hardDelete: listingDeleteHard,
|
hardDelete: listingDeleteHard,
|
||||||
});
|
});
|
||||||
await actions.userSettings.getUserSettings();
|
await actions.userSettings.getUserSettings();
|
||||||
Toast.success('Settings saved. Distance calculations are running in the background.');
|
Toast.success(t('settings.userSettingsSaved'));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Toast.error(error.json?.error || 'Error while saving settings');
|
Toast.error(error.json?.error || t('settings.userSettingsSaveError'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -266,7 +443,7 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="generalSettings">
|
<div className="generalSettings">
|
||||||
<Headline text="Settings" />
|
<Headline text={t('settings.title')} />
|
||||||
{!loading && (
|
{!loading && (
|
||||||
<>
|
<>
|
||||||
<Tabs type="line">
|
<Tabs type="line">
|
||||||
@@ -274,17 +451,17 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
tab={
|
tab={
|
||||||
<span>
|
<span>
|
||||||
<IconSignal size="small" style={{ marginRight: 6 }} />
|
<IconSignal size="small" style={{ marginRight: 6 }} />
|
||||||
System
|
{t('settings.tabSystem')}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
itemKey="system"
|
itemKey="system"
|
||||||
>
|
>
|
||||||
<div className="generalSettings__tab-content">
|
<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
|
<InputNumber
|
||||||
min={0}
|
min={0}
|
||||||
max={99999}
|
max={99999}
|
||||||
placeholder="Port"
|
placeholder={t('settings.portPlaceholder')}
|
||||||
value={port}
|
value={port}
|
||||||
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
||||||
onChange={(value) => setPort(value)}
|
onChange={(value) => setPort(value)}
|
||||||
@@ -292,53 +469,46 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
/>
|
/>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
|
||||||
<SegmentPart
|
<SegmentPart name={t('settings.baseUrl')} helpText={t('settings.baseUrlHelp')}>
|
||||||
name="Base URL"
|
<Input
|
||||||
helpText="Public URL where Fredy is reachable (e.g. http://192.168.1.10:9998). Used for 'Open in Fredy' links in notifications."
|
type="text"
|
||||||
>
|
placeholder={t('settings.baseUrlPlaceholder')}
|
||||||
<Input type="text" placeholder="Base-Url" value={baseUrl} onChange={(value) => setBaseUrl(value)} />
|
value={baseUrl}
|
||||||
|
onChange={(value) => setBaseUrl(value)}
|
||||||
|
/>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
|
||||||
<SegmentPart
|
<SegmentPart name={t('settings.sqlitePath')} helpText={t('settings.sqlitePathHelp')}>
|
||||||
name="SQLite Database Path"
|
|
||||||
helpText="The directory where Fredy stores its SQLite database files."
|
|
||||||
>
|
|
||||||
<Banner
|
<Banner
|
||||||
fullMode={false}
|
fullMode={false}
|
||||||
type="warning"
|
type="warning"
|
||||||
closeIcon={null}
|
closeIcon={null}
|
||||||
style={{ marginBottom: '12px' }}
|
style={{ marginBottom: '12px' }}
|
||||||
description="Changing this path may result in data loss. Restart Fredy immediately after saving."
|
description={t('settings.sqlitePathWarning')}
|
||||||
/>
|
/>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Database folder path"
|
placeholder={t('settings.sqlitePathPlaceholder')}
|
||||||
value={sqlitePath}
|
value={sqlitePath}
|
||||||
onChange={(value) => setSqlitePath(value)}
|
onChange={(value) => setSqlitePath(value)}
|
||||||
/>
|
/>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
|
||||||
<SegmentPart
|
<SegmentPart name={t('settings.analytics')} helpText={t('settings.analyticsHelp')}>
|
||||||
name="Analytics"
|
|
||||||
helpText="Anonymous usage data to help improve Fredy - provider names, adapter names, OS, Node version, and architecture."
|
|
||||||
>
|
|
||||||
<Checkbox checked={analyticsEnabled} onChange={(e) => setAnalyticsEnabled(e.target.checked)}>
|
<Checkbox checked={analyticsEnabled} onChange={(e) => setAnalyticsEnabled(e.target.checked)}>
|
||||||
Enable analytics
|
{t('settings.analyticsEnable')}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
|
||||||
<SegmentPart
|
<SegmentPart name={t('settings.demoMode')} helpText={t('settings.demoModeHelp')}>
|
||||||
name="Demo Mode"
|
|
||||||
helpText="In demo mode, Fredy will not search for real estates and all data resets to defaults at midnight."
|
|
||||||
>
|
|
||||||
<Checkbox checked={demoMode} onChange={(e) => setDemoMode(e.target.checked)}>
|
<Checkbox checked={demoMode} onChange={(e) => setDemoMode(e.target.checked)}>
|
||||||
Enable demo mode
|
{t('settings.demoModeEnable')}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
|
||||||
<div className="generalSettings__save-row">
|
<div className="generalSettings__save-row">
|
||||||
<Button type="primary" theme="solid" onClick={handleStore} icon={<IconSave />}>
|
<Button type="primary" theme="solid" onClick={handleStore} icon={<IconSave />}>
|
||||||
Save
|
{t('settings.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -348,36 +518,30 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
tab={
|
tab={
|
||||||
<span>
|
<span>
|
||||||
<IconRefresh size="small" style={{ marginRight: 6 }} />
|
<IconRefresh size="small" style={{ marginRight: 6 }} />
|
||||||
Execution
|
{t('settings.tabExecution')}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
itemKey="execution"
|
itemKey="execution"
|
||||||
>
|
>
|
||||||
<div className="generalSettings__tab-content">
|
<div className="generalSettings__tab-content">
|
||||||
<SegmentPart
|
<SegmentPart name={t('settings.searchInterval')} helpText={t('settings.searchIntervalHelp')}>
|
||||||
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."
|
|
||||||
>
|
|
||||||
<InputNumber
|
<InputNumber
|
||||||
min={5}
|
min={5}
|
||||||
max={1440}
|
max={1440}
|
||||||
placeholder="Interval in minutes"
|
placeholder={t('settings.searchIntervalPlaceholder')}
|
||||||
value={interval}
|
value={interval}
|
||||||
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
formatter={(value) => `${value}`.replace(/\D/g, '')}
|
||||||
onChange={(value) => setInterval(value)}
|
onChange={(value) => setInterval(value)}
|
||||||
suffix={'minutes'}
|
suffix={t('settings.searchIntervalSuffix')}
|
||||||
style={{ maxWidth: 200 }}
|
style={{ maxWidth: 200 }}
|
||||||
/>
|
/>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
|
||||||
<SegmentPart
|
<SegmentPart name={t('settings.workingHours')} helpText={t('settings.workingHoursHelp')}>
|
||||||
name="Working Hours"
|
|
||||||
helpText="Fredy will only search for listings during these hours. Leave empty to search around the clock."
|
|
||||||
>
|
|
||||||
<div className="generalSettings__timePickerContainer">
|
<div className="generalSettings__timePickerContainer">
|
||||||
<TimePicker
|
<TimePicker
|
||||||
format={'HH:mm'}
|
format={'HH:mm'}
|
||||||
insetLabel="From"
|
insetLabel={t('settings.workingHoursFrom')}
|
||||||
value={formatFromTBackend(workingHourFrom)}
|
value={formatFromTBackend(workingHourFrom)}
|
||||||
placeholder=""
|
placeholder=""
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
@@ -386,7 +550,7 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
/>
|
/>
|
||||||
<TimePicker
|
<TimePicker
|
||||||
format={'HH:mm'}
|
format={'HH:mm'}
|
||||||
insetLabel="Until"
|
insetLabel={t('settings.workingHoursUntil')}
|
||||||
value={formatFromTBackend(workingHourTo)}
|
value={formatFromTBackend(workingHourTo)}
|
||||||
placeholder=""
|
placeholder=""
|
||||||
onChange={(val) => {
|
onChange={(val) => {
|
||||||
@@ -396,13 +560,10 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
</div>
|
</div>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
|
||||||
<SegmentPart
|
<SegmentPart name={t('settings.proxyUrl')} helpText={t('settings.proxyUrlHelp')}>
|
||||||
name="Proxy URL"
|
|
||||||
helpText="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."
|
|
||||||
>
|
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="http://user:pass@host:port"
|
placeholder={t('settings.proxyUrlPlaceholder')}
|
||||||
value={proxyUrl}
|
value={proxyUrl}
|
||||||
onChange={(value) => setProxyUrl(value)}
|
onChange={(value) => setProxyUrl(value)}
|
||||||
/>
|
/>
|
||||||
@@ -410,7 +571,7 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
|
|
||||||
<div className="generalSettings__save-row">
|
<div className="generalSettings__save-row">
|
||||||
<Button type="primary" theme="solid" onClick={handleStore} icon={<IconSave />}>
|
<Button type="primary" theme="solid" onClick={handleStore} icon={<IconSave />}>
|
||||||
Save
|
{t('settings.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -420,42 +581,55 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
tab={
|
tab={
|
||||||
<span>
|
<span>
|
||||||
<IconHome size="small" style={{ marginRight: 6 }} />
|
<IconHome size="small" style={{ marginRight: 6 }} />
|
||||||
User Settings
|
{t('settings.tabUserSettings')}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
itemKey="userSettings"
|
itemKey="userSettings"
|
||||||
>
|
>
|
||||||
<div className="generalSettings__tab-content">
|
<div className="generalSettings__tab-content">
|
||||||
<SegmentPart
|
<SegmentPart name={t('settings.language')} helpText={t('settings.languageHelp')}>
|
||||||
name="Home Address"
|
<Select
|
||||||
helpText="Used to calculate distances between your location and each listing. Updating this recalculates distances for all active listings."
|
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
|
<AutoComplete
|
||||||
data={dataSource}
|
data={dataSource}
|
||||||
value={address}
|
value={address}
|
||||||
showClear
|
showClear
|
||||||
onChange={(v) => setAddress(v)}
|
onChange={(v) => setAddress(v)}
|
||||||
onSearch={searchAddress}
|
onSearch={searchAddress}
|
||||||
placeholder="Enter your home address"
|
placeholder={t('settings.homeAddressPlaceholder')}
|
||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
/>
|
/>
|
||||||
{coords && coords.lat === -1 && (
|
{coords && coords.lat === -1 && (
|
||||||
<Banner
|
<Banner
|
||||||
type="danger"
|
type="danger"
|
||||||
description="Address found but could not be geocoded accurately."
|
description={t('settings.homeAddressGeoError')}
|
||||||
closeIcon={null}
|
closeIcon={null}
|
||||||
style={{ marginTop: 8 }}
|
style={{ marginTop: 8 }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
|
||||||
<SegmentPart
|
<SegmentPart name={t('settings.providerDetails')} helpText={t('settings.providerDetailsHelp')}>
|
||||||
name="Provider Details"
|
|
||||||
helpText="Fetch additional details (description, attributes, agent info) for listings. Needs an extra API call per listing."
|
|
||||||
>
|
|
||||||
<Banner
|
<Banner
|
||||||
type="warning"
|
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}
|
closeIcon={null}
|
||||||
style={{ marginBottom: 12 }}
|
style={{ marginBottom: 12 }}
|
||||||
/>
|
/>
|
||||||
@@ -464,47 +638,57 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
style={{ width: '100%' }}
|
style={{ width: '100%' }}
|
||||||
value={Array.isArray(providerDetails) ? providerDetails : []}
|
value={Array.isArray(providerDetails) ? providerDetails : []}
|
||||||
optionList={(allProviders ?? []).map((p) => ({ label: p.name, value: p.id }))}
|
optionList={(allProviders ?? []).map((p) => ({ label: p.name, value: p.id }))}
|
||||||
placeholder="Select providers to fetch details from..."
|
placeholder={t('settings.providerDetailsPlaceholder')}
|
||||||
onChange={async (selected) => {
|
onChange={async (selected) => {
|
||||||
try {
|
try {
|
||||||
await actions.userSettings.setProviderDetails(selected);
|
await actions.userSettings.setProviderDetails(selected);
|
||||||
Toast.success('Provider details setting updated.');
|
Toast.success(t('settings.providerDetailsUpdated'));
|
||||||
} catch {
|
} catch {
|
||||||
Toast.error('Failed to update setting.');
|
Toast.error(t('settings.providerDetailsUpdateError'));
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
name="Listing deletion"
|
name={t('settings.blacklistFilterOnProviderDetails')}
|
||||||
helpText="Choose the default deletion mode. Soft delete hides them without re-scraping; hard delete removes them from the database."
|
helpText={t('settings.blacklistFilterOnProviderDetailsHelp')}
|
||||||
>
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={blacklistFilterOnProviderDetails === true}
|
||||||
|
onChange={async (e) => {
|
||||||
|
try {
|
||||||
|
await actions.userSettings.setBlacklistFilterOnProviderDetails(e.target.checked);
|
||||||
|
Toast.success(t('settings.blacklistFilterOnProviderDetailsUpdated'));
|
||||||
|
} catch {
|
||||||
|
Toast.error(t('settings.blacklistFilterOnProviderDetailsUpdateError'));
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('settings.blacklistFilterOnProviderDetailsEnable')}
|
||||||
|
</Checkbox>
|
||||||
|
</SegmentPart>
|
||||||
|
|
||||||
|
<SegmentPart name={t('settings.listingDeletion')} helpText={t('settings.listingDeletionHelp')}>
|
||||||
<RadioGroup
|
<RadioGroup
|
||||||
value={listingDeleteHard ? 'hard' : 'soft'}
|
value={listingDeleteHard ? 'hard' : 'soft'}
|
||||||
onChange={(e) => setListingDeleteHard(e.target.value === 'hard')}
|
onChange={(e) => setListingDeleteHard(e.target.value === 'hard')}
|
||||||
>
|
>
|
||||||
<Radio value="soft">
|
<Radio value="soft">
|
||||||
<div>
|
<div>
|
||||||
<Text strong>Mark as deleted (Soft Delete)</Text>
|
<Text strong>{t('settings.listingDeletionSoftLabel')}</Text>
|
||||||
<br />
|
<br />
|
||||||
<Text type="secondary">
|
<Text type="secondary">{t('settings.listingDeletionSoftDesc')}</Text>
|
||||||
Listings are kept in the database but marked as hidden. They will <b>not</b> re-appear during
|
|
||||||
the next scraping session.
|
|
||||||
</Text>
|
|
||||||
</div>
|
</div>
|
||||||
</Radio>
|
</Radio>
|
||||||
<Radio value="hard">
|
<Radio value="hard">
|
||||||
<div>
|
<div>
|
||||||
<Text strong>Remove from database (Hard Delete)</Text>
|
<Text strong>{t('settings.listingDeletionHardLabel')}</Text>
|
||||||
<br />
|
<br />
|
||||||
<Text type="secondary">
|
<Text type="secondary">
|
||||||
Listings are completely removed from the database.
|
{t('settings.listingDeletionHardDesc')}
|
||||||
<br />
|
<br />
|
||||||
<Text type="warning">
|
<Text type="warning">{t('settings.listingDeletionHardConsequence')}</Text>
|
||||||
Consequence: They might re-appear when scraping the next time because Fredy won't know they
|
|
||||||
were previously found.
|
|
||||||
</Text>
|
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
</Radio>
|
</Radio>
|
||||||
@@ -514,7 +698,7 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
onChange={(e) => setListingDeleteSkipPrompt(e.target.checked)}
|
onChange={(e) => setListingDeleteSkipPrompt(e.target.checked)}
|
||||||
style={{ marginTop: 12 }}
|
style={{ marginTop: 12 }}
|
||||||
>
|
>
|
||||||
Skip confirmation dialog
|
{t('settings.listingDeletionSkipPrompt')}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
|
||||||
@@ -526,7 +710,7 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
onClick={handleSaveUserSettings}
|
onClick={handleSaveUserSettings}
|
||||||
loading={saving}
|
loading={saving}
|
||||||
>
|
>
|
||||||
Save
|
{t('settings.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -536,7 +720,7 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
tab={
|
tab={
|
||||||
<span>
|
<span>
|
||||||
<IconFolder size="small" style={{ marginRight: 6 }} />
|
<IconFolder size="small" style={{ marginRight: 6 }} />
|
||||||
Backup & Restore
|
{t('settings.tabBackup')}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
itemKey="backup"
|
itemKey="backup"
|
||||||
@@ -548,13 +732,10 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
type="warning"
|
type="warning"
|
||||||
closeIcon={null}
|
closeIcon={null}
|
||||||
style={{ marginBottom: '12px' }}
|
style={{ marginBottom: '12px' }}
|
||||||
description="Backup and restore are not available in demo mode."
|
description={t('settings.backupDemoWarning')}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<SegmentPart
|
<SegmentPart name={t('settings.backupSectionName')} helpText={t('settings.backupHelp')}>
|
||||||
name="Backup & Restore"
|
|
||||||
helpText="Download a zipped backup of your database or restore from a backup zip."
|
|
||||||
>
|
|
||||||
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||||
<Button
|
<Button
|
||||||
theme="solid"
|
theme="solid"
|
||||||
@@ -562,7 +743,7 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
onClick={handleDownloadBackup}
|
onClick={handleDownloadBackup}
|
||||||
disabled={demoMode && !currentUser?.isAdmin}
|
disabled={demoMode && !currentUser?.isAdmin}
|
||||||
>
|
>
|
||||||
Download Backup
|
{t('settings.backupDownload')}
|
||||||
</Button>
|
</Button>
|
||||||
<input
|
<input
|
||||||
type="file"
|
type="file"
|
||||||
@@ -577,23 +758,115 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
icon={<IconFolder />}
|
icon={<IconFolder />}
|
||||||
disabled={demoMode && !currentUser?.isAdmin}
|
disabled={demoMode && !currentUser?.isAdmin}
|
||||||
>
|
>
|
||||||
Restore from Zip
|
{t('settings.backupRestoreFromZip')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
</div>
|
</div>
|
||||||
</TabPane>
|
</TabPane>
|
||||||
|
|
||||||
|
{currentUser?.isAdmin && (
|
||||||
|
<TabPane
|
||||||
|
tab={
|
||||||
|
<span>
|
||||||
|
<IconAlertTriangle
|
||||||
|
size="small"
|
||||||
|
style={{
|
||||||
|
marginRight: 6,
|
||||||
|
color: debugStatus?.enabled ? 'var(--semi-color-danger)' : undefined,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{t('settings.tabDebug')}
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
itemKey="debug"
|
||||||
|
>
|
||||||
|
<div className="generalSettings__tab-content">
|
||||||
|
<SegmentPart name={t('settings.debugSectionName')}>
|
||||||
|
<Banner
|
||||||
|
type="info"
|
||||||
|
fullMode={false}
|
||||||
|
closeIcon={null}
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
title={<div style={{ fontWeight: 600, fontSize: '14px' }}>{t('settings.debugInfoTitle')}</div>}
|
||||||
|
description={t('settings.debugInfoDescription')}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{debugStatus?.enabled ? (
|
||||||
|
<Banner
|
||||||
|
type="danger"
|
||||||
|
fullMode={false}
|
||||||
|
closeIcon={null}
|
||||||
|
style={{ marginBottom: 12 }}
|
||||||
|
description={
|
||||||
|
<div>
|
||||||
|
<div style={{ fontWeight: 600 }}>{t('settings.debugStatusActive')}</div>
|
||||||
|
<div style={{ marginTop: 8 }}>
|
||||||
|
<Text type="secondary" style={{ marginRight: 8 }}>
|
||||||
|
{t('settings.debugUsedLabel')}
|
||||||
|
</Text>
|
||||||
|
<Text>
|
||||||
|
{t('settings.debugUsedValue', {
|
||||||
|
used: formatBytes(debugStatus.size),
|
||||||
|
max: formatBytes(debugStatus.max),
|
||||||
|
percent: percentOf(debugStatus.size, debugStatus.max),
|
||||||
|
})}
|
||||||
|
</Text>
|
||||||
|
<Progress
|
||||||
|
percent={percentOf(debugStatus.size, debugStatus.max)}
|
||||||
|
stroke="var(--semi-color-danger)"
|
||||||
|
aria-label="debug log storage"
|
||||||
|
style={{ marginTop: 6 }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div style={{ marginBottom: 12 }}>
|
||||||
|
<Text type="secondary">{t('settings.debugStatusInactive')}</Text>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<Button
|
||||||
|
theme="solid"
|
||||||
|
type={debugStatus?.enabled ? 'danger' : 'primary'}
|
||||||
|
loading={debugBusy}
|
||||||
|
disabled={debugStatus == null}
|
||||||
|
onClick={handleToggleDebugLogging}
|
||||||
|
>
|
||||||
|
{debugStatus?.enabled ? t('settings.debugDisableButton') : t('settings.debugEnableButton')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
theme="light"
|
||||||
|
icon={<IconSave />}
|
||||||
|
disabled={debugStatus == null || !debugStatus?.everEnabled || !debugStatus?.hasLogs}
|
||||||
|
onClick={handleDownloadDebugBundle}
|
||||||
|
>
|
||||||
|
{t('settings.debugDownloadButton')}
|
||||||
|
</Button>
|
||||||
|
{debugStatus?.hasLogs && (
|
||||||
|
<Button theme="solid" type="warning" onClick={() => setDebugClearConfirmVisible(true)}>
|
||||||
|
{t('settings.debugClearButton')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SegmentPart>
|
||||||
|
</div>
|
||||||
|
</TabPane>
|
||||||
|
)}
|
||||||
</Tabs>
|
</Tabs>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{restoreModalVisible && (
|
{restoreModalVisible && (
|
||||||
<Modal
|
<Modal
|
||||||
title="Restore database"
|
title={t('settings.restoreModalTitle')}
|
||||||
visible={restoreModalVisible}
|
visible={restoreModalVisible}
|
||||||
onCancel={() => setRestoreModalVisible(false)}
|
onCancel={() => setRestoreModalVisible(false)}
|
||||||
onOk={() => performRestore(!precheckInfo?.compatible)}
|
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'}
|
okType={precheckInfo?.compatible ? 'primary' : 'danger'}
|
||||||
confirmLoading={restoreBusy}
|
confirmLoading={restoreBusy}
|
||||||
>
|
>
|
||||||
@@ -602,7 +875,7 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
type="danger"
|
type="danger"
|
||||||
fullMode={false}
|
fullMode={false}
|
||||||
closeIcon={null}
|
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>}
|
description={<div>{precheckInfo?.message}</div>}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -611,7 +884,7 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
type="warning"
|
type="warning"
|
||||||
fullMode={false}
|
fullMode={false}
|
||||||
closeIcon={null}
|
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>}
|
description={<div>{precheckInfo?.message}</div>}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -620,13 +893,74 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
type="success"
|
type="success"
|
||||||
fullMode={false}
|
fullMode={false}
|
||||||
closeIcon={null}
|
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>}
|
description={<div>{precheckInfo?.message}</div>}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div style={{ marginTop: '0.5rem', fontSize: '12px', color: 'var(--semi-color-text-2)' }}>
|
<div style={{ marginTop: '0.5rem', fontSize: '12px', color: 'var(--semi-color-text-2)' }}>
|
||||||
Backup migration: {precheckInfo?.backupMigration ?? 'unknown'} | Required migration:{' '}
|
{t('settings.restoreMigrationInfo', {
|
||||||
{precheckInfo?.requiredMigration ?? 'unknown'}
|
backupMigration: precheckInfo?.backupMigration ?? 'unknown',
|
||||||
|
requiredMigration: precheckInfo?.requiredMigration ?? 'unknown',
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{debugConfirmVisible && (
|
||||||
|
<Modal
|
||||||
|
title={t('settings.debugConfirmReenableTitle')}
|
||||||
|
visible={debugConfirmVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
// Defensive reset in case a network blip left debugBusy stuck while the
|
||||||
|
// user dismissed the dialog via the X / backdrop.
|
||||||
|
setDebugBusy(false);
|
||||||
|
setDebugConfirmVisible(false);
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<Button onClick={() => performEnableDebug({ clearPrevious: false })} loading={debugBusy}>
|
||||||
|
{t('settings.debugConfirmKeep')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="danger"
|
||||||
|
theme="solid"
|
||||||
|
onClick={() => performEnableDebug({ clearPrevious: true })}
|
||||||
|
loading={debugBusy}
|
||||||
|
>
|
||||||
|
{t('settings.debugConfirmDelete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>{t('settings.debugConfirmReenableMessage')}</div>
|
||||||
|
</Modal>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{debugClearConfirmVisible && (
|
||||||
|
<Modal
|
||||||
|
title={t('settings.debugClearConfirmTitle')}
|
||||||
|
visible={debugClearConfirmVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setDebugBusy(false);
|
||||||
|
setDebugClearConfirmVisible(false);
|
||||||
|
}}
|
||||||
|
footer={
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'flex-end', gap: 8 }}>
|
||||||
|
<Button onClick={() => setDebugClearConfirmVisible(false)} disabled={debugBusy}>
|
||||||
|
{t('settings.debugClearConfirmCancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="warning" theme="solid" onClick={performClearDebugLogs} loading={debugBusy}>
|
||||||
|
{t('settings.debugClearConfirmDelete')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
{t('settings.debugClearConfirmMessage', {
|
||||||
|
recordingState: debugStatus?.enabled
|
||||||
|
? t('settings.debugClearConfirmRecordingOn')
|
||||||
|
: t('settings.debugClearConfirmRecordingOff'),
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -9,16 +9,18 @@ import { IconPlusCircle } from '@douyinfe/semi-icons';
|
|||||||
import JobGrid from '../../components/grid/jobs/JobGrid.jsx';
|
import JobGrid from '../../components/grid/jobs/JobGrid.jsx';
|
||||||
import Headline from '../../components/headline/Headline.jsx';
|
import Headline from '../../components/headline/Headline.jsx';
|
||||||
import './Jobs.less';
|
import './Jobs.less';
|
||||||
|
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
export default function Jobs() {
|
export default function Jobs() {
|
||||||
|
const t = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
return (
|
return (
|
||||||
<div className="jobs">
|
<div className="jobs">
|
||||||
<Headline
|
<Headline
|
||||||
text="Jobs"
|
text={t('jobs.title')}
|
||||||
actions={
|
actions={
|
||||||
<Button type="primary" theme="solid" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}>
|
<Button type="primary" theme="solid" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}>
|
||||||
New Job
|
{t('jobs.newJob')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -27,14 +27,17 @@ import {
|
|||||||
IconUser,
|
IconUser,
|
||||||
IconFilter,
|
IconFilter,
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
|
import { useTranslation } from '../../../services/i18n/i18n.jsx';
|
||||||
const SPEC_FILTERS = [
|
|
||||||
{ key: 'maxPrice', translation: 'Max Price' },
|
|
||||||
{ key: 'minSize', translation: 'Min Size (m²)' },
|
|
||||||
{ key: 'minRooms', translation: 'Min Rooms' },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function JobMutator() {
|
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 jobs = useSelector((state) => state.jobsData.jobs);
|
||||||
const shareableUserList = useSelector((state) => state.jobsData.shareableUserList);
|
const shareableUserList = useSelector((state) => state.jobsData.shareableUserList);
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
@@ -105,7 +108,7 @@ export default function JobMutator() {
|
|||||||
jobId: jobToBeEdit?.id || null,
|
jobId: jobToBeEdit?.id || null,
|
||||||
});
|
});
|
||||||
await actions.jobsData.getJobs();
|
await actions.jobsData.getJobs();
|
||||||
Toast.success('Job successfully saved...');
|
Toast.success(t('jobs.mutation.saved'));
|
||||||
navigate('/jobs');
|
navigate('/jobs');
|
||||||
} catch (Exception) {
|
} catch (Exception) {
|
||||||
console.error(Exception.json.message);
|
console.error(Exception.json.message);
|
||||||
@@ -146,7 +149,7 @@ export default function JobMutator() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Headline
|
<Headline
|
||||||
text={jobToBeEdit ? 'Edit Job' : 'Create new Job'}
|
text={jobToBeEdit ? t('jobs.mutation.editTitle') : t('jobs.mutation.createTitle')}
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
icon={<IconArrowLeft />}
|
icon={<IconArrowLeft />}
|
||||||
@@ -154,17 +157,17 @@ export default function JobMutator() {
|
|||||||
theme="borderless"
|
theme="borderless"
|
||||||
style={{ color: '#909090' }}
|
style={{ color: '#909090' }}
|
||||||
>
|
>
|
||||||
Back
|
{t('jobs.mutation.back')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<form>
|
<form>
|
||||||
<SegmentPart name="Name" Icon={IconPaperclip}>
|
<SegmentPart name={t('jobs.mutation.sectionName')} Icon={IconPaperclip}>
|
||||||
<Input
|
<Input
|
||||||
autoFocus
|
autoFocus
|
||||||
type="text"
|
type="text"
|
||||||
maxLength={40}
|
maxLength={40}
|
||||||
placeholder="Name"
|
placeholder={t('jobs.mutation.namePlaceholder')}
|
||||||
width={6}
|
width={6}
|
||||||
value={name}
|
value={name}
|
||||||
onChange={(value) => setName(value)}
|
onChange={(value) => setName(value)}
|
||||||
@@ -172,13 +175,9 @@ export default function JobMutator() {
|
|||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
name="Providers"
|
name={t('jobs.mutation.sectionProviders')}
|
||||||
Icon={IconBriefcase}
|
Icon={IconBriefcase}
|
||||||
helpText={`
|
helpText={t('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.
|
|
||||||
`}
|
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -189,7 +188,7 @@ export default function JobMutator() {
|
|||||||
setProviderCreationVisibility(true);
|
setProviderCreationVisibility(true);
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Add new Provider
|
{t('jobs.mutation.addProvider')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<ProviderTable
|
<ProviderTable
|
||||||
@@ -206,8 +205,8 @@ export default function JobMutator() {
|
|||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
Icon={IconBell}
|
Icon={IconBell}
|
||||||
name="Notification Adapters"
|
name={t('jobs.mutation.sectionNotifications')}
|
||||||
helpText="Fredy supports multiple ways to notify you about new findings. These are called notification adapter. You can chose between email, Telegram etc."
|
helpText={t('jobs.mutation.notificationsHelp')}
|
||||||
>
|
>
|
||||||
<Button
|
<Button
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -215,7 +214,7 @@ export default function JobMutator() {
|
|||||||
icon={<IconPlusCircle />}
|
icon={<IconPlusCircle />}
|
||||||
onClick={() => setNotificationCreationVisibility(true)}
|
onClick={() => setNotificationCreationVisibility(true)}
|
||||||
>
|
>
|
||||||
Add new Notification Adapter
|
{t('jobs.mutation.addNotification')}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
<NotificationAdapterTable
|
<NotificationAdapterTable
|
||||||
@@ -233,20 +232,20 @@ export default function JobMutator() {
|
|||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
Icon={IconFilter}
|
Icon={IconFilter}
|
||||||
name="Blacklist"
|
name={t('jobs.mutation.sectionBlacklist')}
|
||||||
helpText="If a listing contains one of these words, it will be filtered out. Type in a word, then hit enter."
|
helpText={t('jobs.mutation.blacklistHelp')}
|
||||||
>
|
>
|
||||||
<TagInput
|
<TagInput
|
||||||
value={blacklist || []}
|
value={blacklist || []}
|
||||||
placeholder="Add a word for filtering..."
|
placeholder={t('jobs.mutation.blacklistPlaceholder')}
|
||||||
onChange={(v) => setBlacklist([...v])}
|
onChange={(v) => setBlacklist([...v])}
|
||||||
/>
|
/>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
Icon={IconFilter}
|
Icon={IconFilter}
|
||||||
name="Criteria Filter"
|
name={t('jobs.mutation.sectionCriteriaFilter')}
|
||||||
helpText="Filter listings by specific criteria. Only numbers are allowed. You can leave fields empty if you don't want to filter by them."
|
helpText={t('jobs.mutation.criteriaFilterHelp')}
|
||||||
>
|
>
|
||||||
<div className="jobMutation__specFilter">
|
<div className="jobMutation__specFilter">
|
||||||
{SPEC_FILTERS.map((filter) => (
|
{SPEC_FILTERS.map((filter) => (
|
||||||
@@ -254,7 +253,7 @@ export default function JobMutator() {
|
|||||||
<div className="jobMutation__specFilterLabel">{filter.translation}</div>
|
<div className="jobMutation__specFilterLabel">{filter.translation}</div>
|
||||||
<Input
|
<Input
|
||||||
type="number"
|
type="number"
|
||||||
placeholder="Add a number"
|
placeholder={t('jobs.mutation.criteriaNumberPlaceholder')}
|
||||||
value={specFilter?.[filter.key]}
|
value={specFilter?.[filter.key]}
|
||||||
onChange={(value) => handleSpecFilterChange(filter.key, value)}
|
onChange={(value) => handleSpecFilterChange(filter.key, value)}
|
||||||
/>
|
/>
|
||||||
@@ -265,24 +264,20 @@ export default function JobMutator() {
|
|||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
Icon={IconFilter}
|
Icon={IconFilter}
|
||||||
name="Area Filter"
|
name={t('jobs.mutation.sectionAreaFilter')}
|
||||||
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."
|
helpText={t('jobs.mutation.areaFilterHelp')}
|
||||||
>
|
>
|
||||||
<AreaFilter spatialFilter={spatialFilter} onChange={handleSpatialFilterChange} />
|
<AreaFilter spatialFilter={spatialFilter} onChange={handleSpatialFilterChange} />
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart Icon={IconUser} name={t('jobs.mutation.sectionSharing')} helpText={t('jobs.mutation.sharingHelp')}>
|
||||||
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."
|
|
||||||
>
|
|
||||||
{shareableUserList.length === 0 ? (
|
{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
|
<Select
|
||||||
filter
|
filter
|
||||||
multiple
|
multiple
|
||||||
placeholder="Search user"
|
placeholder={t('jobs.mutation.sharingSearchPlaceholder')}
|
||||||
autoClearSearchValue={false}
|
autoClearSearchValue={false}
|
||||||
defaultValue={shareWithUsers}
|
defaultValue={shareWithUsers}
|
||||||
onChange={(value) => setShareWithUsers(value)}
|
onChange={(value) => setShareWithUsers(value)}
|
||||||
@@ -298,17 +293,17 @@ export default function JobMutator() {
|
|||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart
|
<SegmentPart
|
||||||
Icon={IconPlayCircle}
|
Icon={IconPlayCircle}
|
||||||
name="Job activation"
|
name={t('jobs.mutation.sectionActivation')}
|
||||||
helpText="Whether or not the job is activated. Inactive jobs will be ignored when Fredy checks for new listings."
|
helpText={t('jobs.mutation.activationHelp')}
|
||||||
>
|
>
|
||||||
<Switch className="jobMutation__spaceTop" onChange={(checked) => setEnabled(checked)} checked={enabled} />
|
<Switch className="jobMutation__spaceTop" onChange={(checked) => setEnabled(checked)} checked={enabled} />
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<Button type="danger" style={{ marginRight: '1rem' }} onClick={() => navigate('/jobs')}>
|
<Button type="danger" style={{ marginRight: '1rem' }} onClick={() => navigate('/jobs')}>
|
||||||
Cancel
|
{t('jobs.mutation.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="primary" icon={<IconPlusCircle />} disabled={!isSavingEnabled()} onClick={mutateJob}>
|
<Button type="primary" icon={<IconPlusCircle />} disabled={!isSavingEnabled()} onClick={mutateJob}>
|
||||||
Save
|
{t('jobs.mutation.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</Fragment>
|
</Fragment>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { Banner, Button, Form, Modal, Select, Switch } from '@douyinfe/semi-ui-1
|
|||||||
|
|
||||||
import './NotificationAdapterMutator.less';
|
import './NotificationAdapterMutator.less';
|
||||||
import { useScreenWidth } from '../../../../../hooks/screenWidth.js';
|
import { useScreenWidth } from '../../../../../hooks/screenWidth.js';
|
||||||
|
import { useTranslation } from '../../../../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
const sortAdapter = (a, b) => {
|
const sortAdapter = (a, b) => {
|
||||||
if (a.name < b.name) {
|
if (a.name < b.name) {
|
||||||
@@ -24,11 +25,11 @@ const sortAdapter = (a, b) => {
|
|||||||
return 0;
|
return 0;
|
||||||
};
|
};
|
||||||
|
|
||||||
const validate = (selectedAdapter) => {
|
const validate = (selectedAdapter, t) => {
|
||||||
const results = [];
|
const results = [];
|
||||||
for (let uiElement of Object.values(selectedAdapter.fields || [])) {
|
for (let uiElement of Object.values(selectedAdapter.fields || [])) {
|
||||||
if (uiElement.value == null && !uiElement.optional && uiElement.type !== 'boolean') {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (uiElement.type === 'boolean' && typeof uiElement.value !== 'boolean') {
|
if (uiElement.type === 'boolean' && typeof uiElement.value !== 'boolean') {
|
||||||
@@ -37,16 +38,16 @@ const validate = (selectedAdapter) => {
|
|||||||
if (uiElement.type === 'number') {
|
if (uiElement.type === 'number') {
|
||||||
const numberValue = parseFloat(uiElement.value);
|
const numberValue = parseFloat(uiElement.value);
|
||||||
if (isNaN(numberValue) || numberValue < 0) {
|
if (isNaN(numberValue) || numberValue < 0) {
|
||||||
results.push('A number field cannot contain anything else and must be > 0.');
|
results.push(t('notification.validationNumberField'));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (uiElement.type === 'boolean' && typeof uiElement.value !== 'boolean') {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
if (typeof uiElement.value === 'string' && uiElement.value.length === 0 && !uiElement.optional) {
|
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,6 +71,7 @@ export default function NotificationAdapterMutator({
|
|||||||
editNotificationAdapter,
|
editNotificationAdapter,
|
||||||
onData,
|
onData,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
const t = useTranslation();
|
||||||
const adapter = useSelector((state) => state.notificationAdapter);
|
const adapter = useSelector((state) => state.notificationAdapter);
|
||||||
|
|
||||||
const preFilledSelectedAdapter =
|
const preFilledSelectedAdapter =
|
||||||
@@ -88,7 +90,7 @@ export default function NotificationAdapterMutator({
|
|||||||
|
|
||||||
const onSubmit = (doStore) => {
|
const onSubmit = (doStore) => {
|
||||||
if (doStore) {
|
if (doStore) {
|
||||||
const validationResults = validate(selectedAdapter);
|
const validationResults = validate(selectedAdapter, t);
|
||||||
if (validationResults.length > 0) {
|
if (validationResults.length > 0) {
|
||||||
setValidationMessage(validationResults.join('<br/>'));
|
setValidationMessage(validationResults.join('<br/>'));
|
||||||
return;
|
return;
|
||||||
@@ -114,7 +116,7 @@ export default function NotificationAdapterMutator({
|
|||||||
setValidationMessage(null);
|
setValidationMessage(null);
|
||||||
setSuccessMessage(null);
|
setSuccessMessage(null);
|
||||||
|
|
||||||
const validationResults = validate(selectedAdapter);
|
const validationResults = validate(selectedAdapter, t);
|
||||||
if (validationResults.length > 0) {
|
if (validationResults.length > 0) {
|
||||||
setValidationMessage(validationResults.join('<br/>'));
|
setValidationMessage(validationResults.join('<br/>'));
|
||||||
return;
|
return;
|
||||||
@@ -127,11 +129,9 @@ export default function NotificationAdapterMutator({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
.then(() => {
|
.then(() => {
|
||||||
setSuccessMessage('It seems like it worked! Please check your service.');
|
setSuccessMessage(t('notification.trySuccess'));
|
||||||
})
|
})
|
||||||
.catch((error) =>
|
.catch((error) => setValidationMessage(t('notification.tryError', { error: error.json.message })));
|
||||||
setValidationMessage(`This did not work :-( I've received the following error: ${error.json.message}`),
|
|
||||||
);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const setValue = (selectedAdapter, uiElement, key, value) => {
|
const setValue = (selectedAdapter, uiElement, key, value) => {
|
||||||
@@ -195,37 +195,29 @@ export default function NotificationAdapterMutator({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={title != null ? title : 'Adding a new Notification Adapter'}
|
title={title != null ? title : t('notification.defaultTitle')}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
style={{ width: isMobile ? '95%' : '50rem' }}
|
style={{ width: isMobile ? '95%' : '50rem' }}
|
||||||
onCancel={() => onSubmit(false)}
|
onCancel={() => onSubmit(false)}
|
||||||
footer={
|
footer={
|
||||||
<div>
|
<div>
|
||||||
<Button type="secondary" disabled={selectedAdapter == null} style={{ float: 'left' }} onClick={onTry}>
|
<Button type="secondary" disabled={selectedAdapter == null} style={{ float: 'left' }} onClick={onTry}>
|
||||||
Try
|
{t('notification.try')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button theme="light" type="tertiary" onClick={() => onSubmit(false)}>
|
<Button theme="light" type="tertiary" onClick={() => onSubmit(false)}>
|
||||||
Cancel
|
{t('notification.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button theme="solid" type="primary" onClick={() => onSubmit(true)}>
|
<Button theme="solid" type="primary" onClick={() => onSubmit(true)}>
|
||||||
Save
|
{t('notification.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{description != null ? (
|
{description != null ? <p>{description}</p> : <p>{t('notification.description')}</p>}
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Select
|
<Select
|
||||||
filter
|
filter
|
||||||
placeholder="Select a notification adapter"
|
placeholder={t('notification.selectPlaceholder')}
|
||||||
className="providerMutator__fields"
|
className="providerMutator__fields"
|
||||||
value={selectedAdapter == null ? '' : selectedAdapter.id}
|
value={selectedAdapter == null ? '' : selectedAdapter.id}
|
||||||
optionList={adapter
|
optionList={adapter
|
||||||
@@ -265,7 +257,11 @@ export default function NotificationAdapterMutator({
|
|||||||
fullMode={false}
|
fullMode={false}
|
||||||
type="danger"
|
type="danger"
|
||||||
closeIcon={null}
|
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' }}
|
style={{ marginBottom: '1rem' }}
|
||||||
description={<p dangerouslySetInnerHTML={{ __html: validationMessage }} />}
|
description={<p dangerouslySetInnerHTML={{ __html: validationMessage }} />}
|
||||||
/>
|
/>
|
||||||
@@ -275,7 +271,11 @@ export default function NotificationAdapterMutator({
|
|||||||
fullMode={false}
|
fullMode={false}
|
||||||
type="success"
|
type="success"
|
||||||
closeIcon={null}
|
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' }}
|
style={{ marginBottom: '1rem' }}
|
||||||
description={<p dangerouslySetInnerHTML={{ __html: successMessage }} />}
|
description={<p dangerouslySetInnerHTML={{ __html: successMessage }} />}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -8,9 +8,10 @@ import { useState, useEffect } from 'react';
|
|||||||
import { Banner, Modal, Select, Input } from '@douyinfe/semi-ui-19';
|
import { Banner, Modal, Select, Input } from '@douyinfe/semi-ui-19';
|
||||||
import { transform } from '../../../../../services/transformer/providerTransformer';
|
import { transform } from '../../../../../services/transformer/providerTransformer';
|
||||||
import { useSelector } from '../../../../../services/state/store';
|
import { useSelector } from '../../../../../services/state/store';
|
||||||
import { IconLikeHeart } from '@douyinfe/semi-icons';
|
|
||||||
import './ProviderMutator.less';
|
import './ProviderMutator.less';
|
||||||
import { useScreenWidth } from '../../../../../hooks/screenWidth.js';
|
import { useScreenWidth } from '../../../../../hooks/screenWidth.js';
|
||||||
|
import { useTranslation } from '../../../../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
const sortProvider = (a, b) => {
|
const sortProvider = (a, b) => {
|
||||||
if (a.key < b.key) {
|
if (a.key < b.key) {
|
||||||
@@ -33,6 +34,7 @@ export default function ProviderMutator({
|
|||||||
onEditData,
|
onEditData,
|
||||||
providerToEdit,
|
providerToEdit,
|
||||||
} = {}) {
|
} = {}) {
|
||||||
|
const t = useTranslation();
|
||||||
const provider = useSelector((state) => state.provider);
|
const provider = useSelector((state) => state.provider);
|
||||||
const [selectedProvider, setSelectedProvider] = useState(null);
|
const [selectedProvider, setSelectedProvider] = useState(null);
|
||||||
const [providerUrl, setProviderUrl] = useState(null);
|
const [providerUrl, setProviderUrl] = useState(null);
|
||||||
@@ -53,16 +55,16 @@ export default function ProviderMutator({
|
|||||||
|
|
||||||
const validate = () => {
|
const validate = () => {
|
||||||
if (selectedProvider == null || selectedProvider.length === 0 || providerUrl == null || providerUrl.length === 0) {
|
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 {
|
try {
|
||||||
const url = new URL(providerUrl);
|
const url = new URL(providerUrl);
|
||||||
if (selectedProvider.baseUrl.indexOf(url.origin) === -1) {
|
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 */
|
/* eslint-disable no-unused-vars */
|
||||||
} catch (ignored) {
|
} catch (ignored) {
|
||||||
return 'The url you have copied is not valid.';
|
return t('provider.validationInvalidUrl');
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
};
|
};
|
||||||
@@ -104,46 +106,36 @@ export default function ProviderMutator({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal
|
<Modal
|
||||||
title={providerToEdit ? 'Editing an existing Provider' : 'Adding a new Provider'}
|
title={providerToEdit ? t('provider.editTitle') : t('provider.defaultTitle')}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
onOk={() => onSubmit(true)}
|
onOk={() => onSubmit(true)}
|
||||||
onCancel={() => onSubmit(false)}
|
onCancel={() => onSubmit(false)}
|
||||||
style={{ width: isMobile ? '95%' : '50rem' }}
|
style={{ width: isMobile ? '95%' : '50rem' }}
|
||||||
okText="Save"
|
okText={t('provider.save')}
|
||||||
>
|
>
|
||||||
{validationMessage != null && (
|
{validationMessage != null && (
|
||||||
<Banner
|
<Banner
|
||||||
fullMode={false}
|
fullMode={false}
|
||||||
type="danger"
|
type="danger"
|
||||||
closeIcon={null}
|
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' }}
|
style={{ marginBottom: '1rem' }}
|
||||||
description={validationMessage}
|
description={validationMessage}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
{providerToEdit != null ? (
|
{providerToEdit != null ? (
|
||||||
<p>
|
<p>{t('provider.editDescription', { name: providerToEdit.name })}</p>
|
||||||
You can now edit the <strong>{providerToEdit.name}</strong> provider's URL in the input field below.
|
|
||||||
</p>
|
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p>
|
<p>{t('provider.description')}</p>
|
||||||
Provider are the <IconLikeHeart style={{ color: '#ff0000' }} /> of Fredy. We're supporting multiple Provider
|
<p>{t('provider.descriptionStep2')}</p>
|
||||||
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>
|
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Select
|
<Select
|
||||||
filter
|
filter
|
||||||
placeholder="Select a provider"
|
placeholder={t('provider.selectPlaceholder')}
|
||||||
className="providerMutator__fields"
|
className="providerMutator__fields"
|
||||||
disabled={providerToEdit != null}
|
disabled={providerToEdit != null}
|
||||||
optionList={provider
|
optionList={provider
|
||||||
@@ -167,7 +159,7 @@ export default function ProviderMutator({
|
|||||||
<br />
|
<br />
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Provider Url"
|
placeholder={t('provider.urlPlaceholder')}
|
||||||
width={10}
|
width={10}
|
||||||
className="providerMutator__fields"
|
className="providerMutator__fields"
|
||||||
value={providerUrl}
|
value={providerUrl}
|
||||||
|
|||||||
@@ -49,6 +49,7 @@ import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
|
|||||||
import Headline from '../../components/headline/Headline.jsx';
|
import Headline from '../../components/headline/Headline.jsx';
|
||||||
import StatusControl from '../../components/listings/StatusControl.jsx';
|
import StatusControl from '../../components/listings/StatusControl.jsx';
|
||||||
import './ListingDetail.less';
|
import './ListingDetail.less';
|
||||||
|
import { useTranslation, useLocale } from '../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
@@ -57,6 +58,8 @@ const STYLES = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
export default function ListingDetail() {
|
export default function ListingDetail() {
|
||||||
|
const t = useTranslation();
|
||||||
|
const locale = useLocale();
|
||||||
const { listingId } = useParams();
|
const { listingId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
@@ -79,7 +82,7 @@ export default function ListingDetail() {
|
|||||||
await actions.listingsData.getListing(listingId);
|
await actions.listingsData.getListing(listingId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to load listing details:', e);
|
console.error('Failed to load listing details:', e);
|
||||||
Toast.error('Failed to load listing details');
|
Toast.error(t('listing.detail.toastLoadError'));
|
||||||
navigate('/listings');
|
navigate('/listings');
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
@@ -114,13 +117,21 @@ export default function ListingDetail() {
|
|||||||
|
|
||||||
new maplibregl.Marker({ color: '#3FB1CE' })
|
new maplibregl.Marker({ color: '#3FB1CE' })
|
||||||
.setLngLat([listing.longitude, listing.latitude])
|
.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);
|
.addTo(map.current);
|
||||||
|
|
||||||
if (homeAddress?.coords) {
|
if (homeAddress?.coords) {
|
||||||
new maplibregl.Marker({ color: 'red' })
|
new maplibregl.Marker({ color: 'red' })
|
||||||
.setLngLat([homeAddress.coords.lng, homeAddress.coords.lat])
|
.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);
|
.addTo(map.current);
|
||||||
|
|
||||||
const bounds = getBoundsFromCoords([
|
const bounds = getBoundsFromCoords([
|
||||||
@@ -261,10 +272,10 @@ export default function ListingDetail() {
|
|||||||
await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete });
|
await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete });
|
||||||
}
|
}
|
||||||
await xhrDelete('/api/listings/', { ids: [listing.id], hardDelete });
|
await xhrDelete('/api/listings/', { ids: [listing.id], hardDelete });
|
||||||
Toast.success('Listing successfully removed');
|
Toast.success(t('listing.detail.toastDeleted'));
|
||||||
navigate('/listings');
|
navigate('/listings');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
Toast.error(e.message || 'Error deleting listing');
|
Toast.error(e.message || t('listing.detail.toastDeleteError'));
|
||||||
} finally {
|
} finally {
|
||||||
setDeleteModalVisible(false);
|
setDeleteModalVisible(false);
|
||||||
}
|
}
|
||||||
@@ -273,11 +284,13 @@ export default function ListingDetail() {
|
|||||||
const handleWatch = async () => {
|
const handleWatch = async () => {
|
||||||
try {
|
try {
|
||||||
await xhrPost('/api/listings/watch', { listingId: listing.id });
|
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);
|
actions.listingsData.getListing(listingId);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to operate Watchlist:', e);
|
console.error('Failed to operate Watchlist:', e);
|
||||||
Toast.error('Failed to operate Watchlist');
|
Toast.error(t('listing.detail.toastWatchlistError'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -285,10 +298,10 @@ export default function ListingDetail() {
|
|||||||
try {
|
try {
|
||||||
await actions.listingsData.setListingStatus(listing.id, next);
|
await actions.listingsData.setListingStatus(listing.id, next);
|
||||||
await actions.listingsData.getListing(listingId);
|
await actions.listingsData.getListing(listingId);
|
||||||
Toast.success(next ? `Marked as ${next}` : 'Status cleared');
|
Toast.success(next ? t('listings.toastStatusMarked', { status: next }) : t('listings.toastStatusCleared'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to update status:', e);
|
console.error('Failed to update status:', e);
|
||||||
Toast.error('Failed to update status');
|
Toast.error(t('listings.toastStatusUpdateError'));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -298,10 +311,10 @@ export default function ListingDetail() {
|
|||||||
try {
|
try {
|
||||||
await actions.listingsData.setListingNotes(listing.id, notesDraft);
|
await actions.listingsData.setListingNotes(listing.id, notesDraft);
|
||||||
await actions.listingsData.getListing(listingId);
|
await actions.listingsData.getListing(listingId);
|
||||||
Toast.success('Notes saved');
|
Toast.success(t('listing.detail.toastNotesSaved'));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('Failed to save notes:', e);
|
console.error('Failed to save notes:', e);
|
||||||
Toast.error('Failed to save notes');
|
Toast.error(t('listing.detail.toastNotesError'));
|
||||||
} finally {
|
} finally {
|
||||||
setNotesSaving(false);
|
setNotesSaving(false);
|
||||||
}
|
}
|
||||||
@@ -317,69 +330,74 @@ export default function ListingDetail() {
|
|||||||
|
|
||||||
if (!listing) return null;
|
if (!listing) return null;
|
||||||
|
|
||||||
const statusLabel = listing.status?.status
|
const statusKeyMap = {
|
||||||
? listing.status.status.charAt(0).toUpperCase() + listing.status.status.slice(1)
|
applied: 'listing.detail.statusApplied',
|
||||||
: null;
|
accepted: 'listing.detail.statusAccepted',
|
||||||
|
rejected: 'listing.detail.statusRejected',
|
||||||
|
};
|
||||||
|
const statusLabel = listing.status?.status ? t(statusKeyMap[listing.status.status] ?? listing.status.status) : null;
|
||||||
|
|
||||||
const data = [
|
const data = [
|
||||||
{
|
{
|
||||||
key: 'Price',
|
key: t('listing.detail.fieldPrice'),
|
||||||
value: listing.price ? (
|
value: listing.price ? (
|
||||||
<span className="listing-detail__price">{formatEuroPrice(listing.price)}</span>
|
<span className="listing-detail__price">{formatEuroPrice(listing.price)}</span>
|
||||||
) : (
|
) : (
|
||||||
'N/A'
|
t('common.na')
|
||||||
),
|
),
|
||||||
Icon: <IconCart />,
|
Icon: <IconCart />,
|
||||||
helpText: 'The asking price of this listing, as reported by the provider.',
|
helpText: t('listing.detail.fieldPriceHelp'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'Size',
|
key: t('listing.detail.fieldSize'),
|
||||||
value: listing.size ? `${listing.size} m²` : 'N/A',
|
value: listing.size ? `${listing.size} m²` : t('common.na'),
|
||||||
Icon: <IconExpand />,
|
Icon: <IconExpand />,
|
||||||
helpText: 'Living space of the listing in square meters.',
|
helpText: t('listing.detail.fieldSizeHelp'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'Rooms',
|
key: t('listing.detail.fieldRooms'),
|
||||||
value: listing.rooms ? `${listing.rooms} Rooms` : 'N/A',
|
value: listing.rooms ? t('listing.detail.fieldRoomsValue', { count: listing.rooms }) : t('common.na'),
|
||||||
Icon: <IconGridView />,
|
Icon: <IconGridView />,
|
||||||
helpText: 'Number of rooms in the listing.',
|
helpText: t('listing.detail.fieldRoomsHelp'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'Job',
|
key: t('listing.detail.fieldJob'),
|
||||||
value: listing.job_name,
|
value: listing.job_name,
|
||||||
Icon: <IconBriefcase />,
|
Icon: <IconBriefcase />,
|
||||||
helpText: 'The Fredy job that found this listing.',
|
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',
|
value: listing.provider ? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1) : 'Unknown',
|
||||||
Icon: <IconBriefcase />,
|
Icon: <IconBriefcase />,
|
||||||
helpText: 'The real estate portal where this listing was scraped from.',
|
helpText: t('listing.detail.fieldProviderHelp'),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'Added',
|
key: t('listing.detail.fieldAdded'),
|
||||||
value: timeService.format(listing.created_at),
|
value: timeService.format(listing.created_at, true, locale),
|
||||||
Icon: <IconClock />,
|
Icon: <IconClock />,
|
||||||
helpText: 'When Fredy first added this listing to your database.',
|
helpText: t('listing.detail.fieldAddedHelp'),
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
if (statusLabel) {
|
if (statusLabel) {
|
||||||
data.push({
|
data.push({
|
||||||
key: 'Status',
|
key: t('listing.detail.fieldStatus'),
|
||||||
value: listing.status?.setAt ? `${statusLabel} (set ${timeService.format(listing.status.setAt)})` : statusLabel,
|
value: listing.status?.setAt
|
||||||
|
? `${statusLabel} ${t('listing.detail.statusSetAt', { date: timeService.format(listing.status.setAt, true, locale) })}`
|
||||||
|
: statusLabel,
|
||||||
Icon: <IconActivity />,
|
Icon: <IconActivity />,
|
||||||
helpText: 'The status you marked for this listing and when you set it.',
|
helpText: t('listing.detail.fieldStatusHelp'),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="listing-detail">
|
<div className="listing-detail">
|
||||||
<Headline
|
<Headline
|
||||||
text={listing?.title || 'Listing Detail'}
|
text={listing?.title || t('listing.detail.defaultTitle')}
|
||||||
actions={
|
actions={
|
||||||
<Button icon={<IconArrowLeft />} onClick={() => navigate(-1)} theme="borderless" style={{ color: '#909090' }}>
|
<Button icon={<IconArrowLeft />} onClick={() => navigate(-1)} theme="borderless" style={{ color: '#909090' }}>
|
||||||
Back
|
{t('listing.detail.back')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -398,7 +416,7 @@ export default function ListingDetail() {
|
|||||||
{listing.address}
|
{listing.address}
|
||||||
</a>
|
</a>
|
||||||
) : (
|
) : (
|
||||||
<Text type="secondary">No address provided</Text>
|
<Text type="secondary">{t('listing.detail.noAddress')}</Text>
|
||||||
)}
|
)}
|
||||||
</Space>
|
</Space>
|
||||||
<Space wrap className="listing-detail__header-actions">
|
<Space wrap className="listing-detail__header-actions">
|
||||||
@@ -408,12 +426,12 @@ export default function ListingDetail() {
|
|||||||
theme="borderless"
|
theme="borderless"
|
||||||
className={`listing-detail__watch-btn${listing.isWatched === 1 ? ' listing-detail__watch-btn--active' : ''}`}
|
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>
|
</Button>
|
||||||
<StatusControl status={listing.status?.status ?? null} onChange={handleStatusChange} />
|
<StatusControl status={listing.status?.status ?? null} onChange={handleStatusChange} />
|
||||||
<a href={listing.link} target="_blank" rel="noopener noreferrer" className="listing-detail__open-btn">
|
<a href={listing.link} target="_blank" rel="noopener noreferrer" className="listing-detail__open-btn">
|
||||||
<IconLink style={{ marginRight: 6 }} />
|
<IconLink style={{ marginRight: 6 }} />
|
||||||
Open listing
|
{t('listing.detail.openListing')}
|
||||||
</a>
|
</a>
|
||||||
<Button
|
<Button
|
||||||
icon={<IconDelete />}
|
icon={<IconDelete />}
|
||||||
@@ -427,7 +445,7 @@ export default function ListingDetail() {
|
|||||||
theme="light"
|
theme="light"
|
||||||
type="danger"
|
type="danger"
|
||||||
>
|
>
|
||||||
Delete
|
{t('listing.detail.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
@@ -439,7 +457,7 @@ export default function ListingDetail() {
|
|||||||
>
|
>
|
||||||
<Image
|
<Image
|
||||||
src={listing.image_url ?? no_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%' }}
|
style={{ width: '100%', height: '100%' }}
|
||||||
preview={!!listing.image_url}
|
preview={!!listing.image_url}
|
||||||
/>
|
/>
|
||||||
@@ -447,12 +465,12 @@ export default function ListingDetail() {
|
|||||||
|
|
||||||
<div className="listing-detail__notes">
|
<div className="listing-detail__notes">
|
||||||
<Title heading={4} className="listing-detail__notes-title">
|
<Title heading={4} className="listing-detail__notes-title">
|
||||||
Notes
|
{t('listing.detail.notesTitle')}
|
||||||
</Title>
|
</Title>
|
||||||
<TextArea
|
<TextArea
|
||||||
value={notesDraft}
|
value={notesDraft}
|
||||||
onChange={(val) => setNotesDraft(val)}
|
onChange={(val) => setNotesDraft(val)}
|
||||||
placeholder="Your private notes about this listing…"
|
placeholder={t('listing.detail.notesPlaceholder')}
|
||||||
rows={5}
|
rows={5}
|
||||||
autosize={{ minRows: 4, maxRows: 12 }}
|
autosize={{ minRows: 4, maxRows: 12 }}
|
||||||
className="listing-detail__notes-textarea"
|
className="listing-detail__notes-textarea"
|
||||||
@@ -466,7 +484,7 @@ export default function ListingDetail() {
|
|||||||
disabled={notesSaving || (notesDraft ?? '') === (listing.notes ?? '')}
|
disabled={notesSaving || (notesDraft ?? '') === (listing.notes ?? '')}
|
||||||
onClick={handleSaveNotes}
|
onClick={handleSaveNotes}
|
||||||
>
|
>
|
||||||
Store notes
|
{t('listing.detail.storeNotes')}
|
||||||
</Button>
|
</Button>
|
||||||
</Space>
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
@@ -474,7 +492,7 @@ export default function ListingDetail() {
|
|||||||
<Col span={24} lg={12}>
|
<Col span={24} lg={12}>
|
||||||
<div className="listing-detail__info-section">
|
<div className="listing-detail__info-section">
|
||||||
<Title heading={4} style={{ marginBottom: '1rem' }}>
|
<Title heading={4} style={{ marginBottom: '1rem' }}>
|
||||||
Details
|
{t('listing.detail.detailsTitle')}
|
||||||
</Title>
|
</Title>
|
||||||
<Descriptions column={1}>
|
<Descriptions column={1}>
|
||||||
{data.map((item, index) => (
|
{data.map((item, index) => (
|
||||||
@@ -490,10 +508,10 @@ export default function ListingDetail() {
|
|||||||
</Descriptions>
|
</Descriptions>
|
||||||
<Divider margin="1.5rem" />
|
<Divider margin="1.5rem" />
|
||||||
<Title heading={4} style={{ marginBottom: '1rem' }}>
|
<Title heading={4} style={{ marginBottom: '1rem' }}>
|
||||||
Description
|
{t('listing.detail.descriptionTitle')}
|
||||||
</Title>
|
</Title>
|
||||||
<Text type="secondary" style={{ whiteSpace: 'pre-wrap' }}>
|
<Text type="secondary" style={{ whiteSpace: 'pre-wrap' }}>
|
||||||
{listing.description || 'No description available.'}
|
{listing.description || t('listing.detail.noDescription')}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
{listing.distance_to_destination && (
|
{listing.distance_to_destination && (
|
||||||
@@ -501,7 +519,7 @@ export default function ListingDetail() {
|
|||||||
<Divider margin="1.5rem" />
|
<Divider margin="1.5rem" />
|
||||||
<Space align="center">
|
<Space align="center">
|
||||||
<IconActivity style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} />
|
<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>
|
<Tag color="blue">{listing.distance_to_destination} m</Tag>
|
||||||
</Space>
|
</Space>
|
||||||
</>
|
</>
|
||||||
@@ -512,12 +530,12 @@ export default function ListingDetail() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<div className="listing-detail__map-wrapper">
|
<div className="listing-detail__map-wrapper">
|
||||||
<Title heading={3}>Location</Title>
|
<Title heading={3}>{t('listing.detail.locationTitle')}</Title>
|
||||||
{!hasGeo ? (
|
{!hasGeo ? (
|
||||||
<Banner
|
<Banner
|
||||||
type="warning"
|
type="warning"
|
||||||
bordered
|
bordered
|
||||||
description="This listing has no valid geocoordinates, so we cannot show it on the map."
|
description={t('listing.detail.noGeoWarning')}
|
||||||
style={{ marginTop: '1rem' }}
|
style={{ marginTop: '1rem' }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -5,12 +5,14 @@
|
|||||||
|
|
||||||
import ListingsOverview from '../../components/listings/ListingsOverview.jsx';
|
import ListingsOverview from '../../components/listings/ListingsOverview.jsx';
|
||||||
import Headline from '../../components/headline/Headline.jsx';
|
import Headline from '../../components/headline/Headline.jsx';
|
||||||
|
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @param {{ mode?: 'all' | 'watchlist' }} props
|
* @param {{ mode?: 'all' | 'watchlist' }} props
|
||||||
*/
|
*/
|
||||||
export default function Listings({ mode = 'all' }) {
|
export default function Listings({ mode = 'all' }) {
|
||||||
const title = mode === 'watchlist' ? 'Watchlist' : 'Listings';
|
const t = useTranslation();
|
||||||
|
const title = mode === 'watchlist' ? t('listings.watchlistTitle') : t('listings.title');
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Headline text={title} />
|
<Headline text={title} />
|
||||||
|
|||||||
@@ -22,12 +22,14 @@ import { Link, useNavigate, useSearchParams } from 'react-router-dom';
|
|||||||
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
|
import ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
|
||||||
import Map from '../../components/map/Map.jsx';
|
import Map from '../../components/map/Map.jsx';
|
||||||
import Headline from '../../components/headline/Headline.jsx';
|
import Headline from '../../components/headline/Headline.jsx';
|
||||||
|
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
const RangeSlider = _RangeSlider?.default ?? _RangeSlider;
|
const RangeSlider = _RangeSlider?.default ?? _RangeSlider;
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
export default function MapView() {
|
export default function MapView() {
|
||||||
|
const t = useTranslation();
|
||||||
const mapContainer = useRef(null);
|
const mapContainer = useRef(null);
|
||||||
const map = useRef(null);
|
const map = useRef(null);
|
||||||
const markers = useRef([]);
|
const markers = useRef([]);
|
||||||
@@ -63,10 +65,10 @@ export default function MapView() {
|
|||||||
await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete });
|
await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete });
|
||||||
}
|
}
|
||||||
await xhrDelete('/api/listings/', { ids: [id], hardDelete });
|
await xhrDelete('/api/listings/', { ids: [id], hardDelete });
|
||||||
Toast.success('Listing successfully removed');
|
Toast.success(t('map.toastDeleted'));
|
||||||
fetchListings();
|
fetchListings();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
Toast.error(error.message || 'Error deleting listing');
|
Toast.error(error.message || t('map.toastDeleteError'));
|
||||||
} finally {
|
} finally {
|
||||||
setDeleteModalVisible(false);
|
setDeleteModalVisible(false);
|
||||||
setListingToDelete(null);
|
setListingToDelete(null);
|
||||||
@@ -187,6 +189,10 @@ export default function MapView() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map.current) return;
|
if (!map.current) return;
|
||||||
|
|
||||||
|
// Use duration: 0 so the map jumps straight to the target view instead of
|
||||||
|
// animating from the zoomed-out initial state. This effect re-runs whenever
|
||||||
|
// listings/filters change, and the fly/zoom animation was distracting on
|
||||||
|
// every refresh.
|
||||||
if (homeAddress?.coords) {
|
if (homeAddress?.coords) {
|
||||||
if (distanceFilter > 0) {
|
if (distanceFilter > 0) {
|
||||||
const bounds = getBoundsFromCenter([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter);
|
const bounds = getBoundsFromCenter([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter);
|
||||||
@@ -194,13 +200,13 @@ export default function MapView() {
|
|||||||
map.current.fitBounds(bounds, {
|
map.current.fitBounds(bounds, {
|
||||||
padding: 20,
|
padding: 20,
|
||||||
maxZoom: 15,
|
maxZoom: 15,
|
||||||
duration: 1000,
|
duration: 0,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
map.current.flyTo({
|
map.current.flyTo({
|
||||||
center: [homeAddress.coords.lng, homeAddress.coords.lat],
|
center: [homeAddress.coords.lng, homeAddress.coords.lat],
|
||||||
zoom: 12,
|
zoom: 12,
|
||||||
duration: 1000,
|
duration: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -214,7 +220,7 @@ export default function MapView() {
|
|||||||
map.current.fitBounds(bounds, {
|
map.current.fitBounds(bounds, {
|
||||||
padding: 50,
|
padding: 50,
|
||||||
maxZoom: 15,
|
maxZoom: 15,
|
||||||
duration: 1000,
|
duration: 0,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -236,7 +242,7 @@ export default function MapView() {
|
|||||||
.setLngLat([homeAddress.coords.lng, homeAddress.coords.lat])
|
.setLngLat([homeAddress.coords.lng, homeAddress.coords.lat])
|
||||||
.setPopup(
|
.setPopup(
|
||||||
new maplibregl.Popup({ offset: 25 }).setHTML(
|
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);
|
.addTo(map.current);
|
||||||
@@ -313,11 +319,11 @@ export default function MapView() {
|
|||||||
/>
|
/>
|
||||||
<h4>${listing.title}</h4>
|
<h4>${listing.title}</h4>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
<span><strong>Price:</strong> ${listing.price ? listing.price + ' €' : 'N/A'}</span>
|
<span><strong>${t('map.popupPrice')}</strong> ${listing.price ? listing.price + ' €' : t('common.na')}</span>
|
||||||
<span><strong>Address:</strong> ${listing.address || 'N/A'}</span>
|
<span><strong>${t('map.popupAddress')}</strong> ${listing.address || t('common.na')}</span>
|
||||||
<span><strong>Job:</strong> ${listing.job_name || 'N/A'}</span>
|
<span><strong>${t('map.popupJob')}</strong> ${listing.job_name || t('common.na')}</span>
|
||||||
<span><strong>Provider:</strong> ${capitalizedProvider}</span>
|
<span><strong>${t('map.popupProvider')}</strong> ${capitalizedProvider}</span>
|
||||||
<span><strong>Size:</strong> ${listing.size != null ? `${listing.size} m²` : 'N/A'}</span>
|
<span><strong>${t('map.popupSize')}</strong> ${listing.size != null ? `${listing.size} m²` : t('common.na')}</span>
|
||||||
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: space-between;">
|
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: space-between;">
|
||||||
<div class="map-popup-content__linkButton">
|
<div class="map-popup-content__linkButton">
|
||||||
<a href="${listing.link}" target="_blank" rel="noopener noreferrer">
|
<a href="${listing.link}" target="_blank" rel="noopener noreferrer">
|
||||||
@@ -326,14 +332,14 @@ export default function MapView() {
|
|||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
class="map-popup-content__detailsButton"
|
class="map-popup-content__detailsButton"
|
||||||
title="View Details"
|
title="${t('map.popupViewDetails')}"
|
||||||
onclick="viewDetails('${listing.id}')"
|
onclick="viewDetails('${listing.id}')"
|
||||||
>
|
>
|
||||||
${renderToString(<IconEyeOpened />)}
|
${renderToString(<IconEyeOpened />)}
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
class="map-popup-content__deleteButton"
|
class="map-popup-content__deleteButton"
|
||||||
title="Remove"
|
title="${t('map.popupRemove')}"
|
||||||
onclick="deleteListing('${listing.id}')"
|
onclick="deleteListing('${listing.id}')"
|
||||||
>
|
>
|
||||||
${renderToString(<IconDelete />)}
|
${renderToString(<IconDelete />)}
|
||||||
@@ -369,7 +375,7 @@ export default function MapView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Headline text="Map View" />
|
<Headline text={t('map.title')} />
|
||||||
<div className="map-view-container">
|
<div className="map-view-container">
|
||||||
{!homeAddress && (
|
{!homeAddress && (
|
||||||
<Banner
|
<Banner
|
||||||
@@ -380,8 +386,9 @@ export default function MapView() {
|
|||||||
style={{ marginBottom: '8px' }}
|
style={{ marginBottom: '8px' }}
|
||||||
description={
|
description={
|
||||||
<span>
|
<span>
|
||||||
No home address set. Configure it in <Link to="/userSettings">user settings</Link> to use the distance
|
{t('map.noHomeAddressBefore')}
|
||||||
filter.
|
<Link to="/userSettings">{t('map.noHomeAddressLink')}</Link>
|
||||||
|
{t('map.noHomeAddressAfter')}
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
@@ -393,7 +400,7 @@ export default function MapView() {
|
|||||||
bordered
|
bordered
|
||||||
closeIcon={null}
|
closeIcon={null}
|
||||||
style={{ marginBottom: '8px' }}
|
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">
|
<div className="map-view-container__map-wrapper">
|
||||||
@@ -408,10 +415,10 @@ export default function MapView() {
|
|||||||
<div className="map-view-container__floating-panel">
|
<div className="map-view-container__floating-panel">
|
||||||
<div className="map-view-container__panel-row">
|
<div className="map-view-container__panel-row">
|
||||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||||
Job
|
{t('map.filterJobLabel')}
|
||||||
</Text>
|
</Text>
|
||||||
<Select
|
<Select
|
||||||
placeholder="All jobs"
|
placeholder={t('map.filterJobPlaceholder')}
|
||||||
showClear
|
showClear
|
||||||
size="small"
|
size="small"
|
||||||
onChange={(val) => setJobId(val)}
|
onChange={(val) => setJobId(val)}
|
||||||
@@ -428,16 +435,16 @@ export default function MapView() {
|
|||||||
|
|
||||||
<div className="map-view-container__panel-row">
|
<div className="map-view-container__panel-row">
|
||||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||||
Distance
|
{t('map.filterDistanceLabel')}
|
||||||
</Text>
|
</Text>
|
||||||
<Select
|
<Select
|
||||||
placeholder="None"
|
placeholder={t('map.filterDistanceNone')}
|
||||||
size="small"
|
size="small"
|
||||||
onChange={(val) => setDistanceFilter(val)}
|
onChange={(val) => setDistanceFilter(val)}
|
||||||
value={distanceFilter}
|
value={distanceFilter}
|
||||||
style={{ width: 100 }}
|
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={5}>5 km</Select.Option>
|
||||||
<Select.Option value={10}>10 km</Select.Option>
|
<Select.Option value={10}>10 km</Select.Option>
|
||||||
<Select.Option value={15}>15 km</Select.Option>
|
<Select.Option value={15}>15 km</Select.Option>
|
||||||
@@ -448,7 +455,7 @@ export default function MapView() {
|
|||||||
|
|
||||||
<div className="map-view-container__panel-row">
|
<div className="map-view-container__panel-row">
|
||||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||||
Price (€)
|
{t('map.filterPriceLabel')}
|
||||||
</Text>
|
</Text>
|
||||||
<div className="map-view-container__price-slider">
|
<div className="map-view-container__price-slider">
|
||||||
<div className="map__rangesliderLabels">
|
<div className="map__rangesliderLabels">
|
||||||
@@ -461,17 +468,17 @@ export default function MapView() {
|
|||||||
|
|
||||||
<div className="map-view-container__panel-row">
|
<div className="map-view-container__panel-row">
|
||||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||||
Style
|
{t('map.filterStyleLabel')}
|
||||||
</Text>
|
</Text>
|
||||||
<Select size="small" value={style} onChange={(val) => handleMapStyle(val)} style={{ width: 110 }}>
|
<Select size="small" value={style} onChange={(val) => handleMapStyle(val)} style={{ width: 110 }}>
|
||||||
<Select.Option value="STANDARD">Standard</Select.Option>
|
<Select.Option value="STANDARD">{t('map.filterStyleStandard')}</Select.Option>
|
||||||
<Select.Option value="SATELLITE">Satellite</Select.Option>
|
<Select.Option value="SATELLITE">{t('map.filterStyleSatellite')}</Select.Option>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="map-view-container__panel-row">
|
<div className="map-view-container__panel-row">
|
||||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||||
3D Buildings
|
{t('map.filter3dBuildings')}
|
||||||
</Text>
|
</Text>
|
||||||
<Switch
|
<Switch
|
||||||
size="small"
|
size="small"
|
||||||
|
|||||||
@@ -8,8 +8,10 @@ import { IconHorn } from '@douyinfe/semi-icons';
|
|||||||
import { SegmentPart } from '../../../components/segment/SegmentPart.jsx';
|
import { SegmentPart } from '../../../components/segment/SegmentPart.jsx';
|
||||||
import { Banner, Button, Checkbox, Space, Typography } from '@douyinfe/semi-ui-19';
|
import { Banner, Button, Checkbox, Space, Typography } from '@douyinfe/semi-ui-19';
|
||||||
import NotificationAdapterMutator from '../../jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.jsx';
|
import NotificationAdapterMutator from '../../jobs/mutation/components/notificationAdapter/NotificationAdapterMutator.jsx';
|
||||||
|
import { useTranslation } from '../../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
export default function WatchlistManagement() {
|
export default function WatchlistManagement() {
|
||||||
|
const t = useTranslation();
|
||||||
const [notificationChooserVisible, setNotificationChooserVisible] = useState(false);
|
const [notificationChooserVisible, setNotificationChooserVisible] = useState(false);
|
||||||
const [notificationAdapterData, setNotificationAdapterData] = useState([]);
|
const [notificationAdapterData, setNotificationAdapterData] = useState([]);
|
||||||
//TODO: Set default
|
//TODO: Set default
|
||||||
@@ -17,39 +19,37 @@ export default function WatchlistManagement() {
|
|||||||
const [priceChanges, setPriceChanges] = useState(false);
|
const [priceChanges, setPriceChanges] = useState(false);
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<SegmentPart
|
<SegmentPart name={t('watchlist.sectionName')} helpText={t('watchlist.sectionHelp')} Icon={IconHorn}>
|
||||||
name="Notification for Watch List"
|
|
||||||
helpText="You can get notified for changes on listings from your watch list."
|
|
||||||
Icon={IconHorn}
|
|
||||||
>
|
|
||||||
<Banner
|
<Banner
|
||||||
fullMode={false}
|
fullMode={false}
|
||||||
type="info"
|
type="info"
|
||||||
closeIcon={null}
|
closeIcon={null}
|
||||||
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Note</div>}
|
title={
|
||||||
description="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."
|
<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>{t('watchlist.noteTitle')}</div>
|
||||||
|
}
|
||||||
|
description={t('watchlist.noteDescription')}
|
||||||
/>
|
/>
|
||||||
<Space />
|
<Space />
|
||||||
<Typography.Title heading={5} style={{ marginTop: '1rem' }}>
|
<Typography.Title heading={5} style={{ marginTop: '1rem' }}>
|
||||||
Notify me when:
|
{t('watchlist.notifyMeWhen')}
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
|
|
||||||
<Checkbox checked={activityChanges} onChange={(e) => setActivityChanges(e.target.checked)}>
|
<Checkbox checked={activityChanges} onChange={(e) => setActivityChanges(e.target.checked)}>
|
||||||
Listing state changes (e.g. listing becomes inactive)
|
{t('watchlist.activityChanges')}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
<Checkbox checked={priceChanges} onChange={(e) => setPriceChanges(e.target.checked)}>
|
<Checkbox checked={priceChanges} onChange={(e) => setPriceChanges(e.target.checked)}>
|
||||||
Listing price changes
|
{t('watchlist.priceChanges')}
|
||||||
</Checkbox>
|
</Checkbox>
|
||||||
|
|
||||||
<Space />
|
<Space />
|
||||||
<Typography.Title heading={5} style={{ marginTop: '1rem' }}>
|
<Typography.Title heading={5} style={{ marginTop: '1rem' }}>
|
||||||
Notify me with:
|
{t('watchlist.notifyMeWith')}
|
||||||
</Typography.Title>
|
</Typography.Title>
|
||||||
<Button onClick={() => setNotificationChooserVisible(true)}>Select notification method</Button>
|
<Button onClick={() => setNotificationChooserVisible(true)}>{t('watchlist.selectNotificationMethod')}</Button>
|
||||||
|
|
||||||
<NotificationAdapterMutator
|
<NotificationAdapterMutator
|
||||||
title="Add notification method"
|
title={t('watchlist.addNotificationTitle')}
|
||||||
description="When something has changed, Fredy will notify you using the selected notification adapter. Note, some adapter like SqLite are not available here."
|
description={t('watchlist.addNotificationDescription')}
|
||||||
visible={notificationChooserVisible}
|
visible={notificationChooserVisible}
|
||||||
onVisibilityChanged={(visible) => {
|
onVisibilityChanged={(visible) => {
|
||||||
setNotificationChooserVisible(visible);
|
setNotificationChooserVisible(visible);
|
||||||
|
|||||||
@@ -8,20 +8,23 @@ import React, { useEffect } from 'react';
|
|||||||
import cityBackground from '../../assets/city_background.jpg';
|
import cityBackground from '../../assets/city_background.jpg';
|
||||||
import Logo from '../../components/logo/Logo';
|
import Logo from '../../components/logo/Logo';
|
||||||
import { xhrPost } from '../../services/xhr';
|
import { xhrPost } from '../../services/xhr';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useActions, useSelector } from '../../services/state/store';
|
import { useActions, useSelector } from '../../services/state/store';
|
||||||
import { Input, Button, Banner } from '@douyinfe/semi-ui-19';
|
import { Input, Button, Banner } from '@douyinfe/semi-ui-19';
|
||||||
|
|
||||||
import './login.less';
|
import './login.less';
|
||||||
import { IconUser, IconLock } from '@douyinfe/semi-icons';
|
import { IconUser, IconLock } from '@douyinfe/semi-icons';
|
||||||
|
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
|
const t = useTranslation();
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
const [username, setUserName] = React.useState('');
|
const [username, setUserName] = React.useState('');
|
||||||
const [password, setPassword] = React.useState('');
|
const [password, setPassword] = React.useState('');
|
||||||
const [error, setError] = React.useState(null);
|
const [error, setError] = React.useState(null);
|
||||||
const demoMode = useSelector((state) => state.demoMode.demoMode || false);
|
const demoMode = useSelector((state) => state.demoMode.demoMode || false);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function init() {
|
async function init() {
|
||||||
@@ -33,7 +36,7 @@ export default function Login() {
|
|||||||
|
|
||||||
const tryLogin = async () => {
|
const tryLogin = async () => {
|
||||||
if (!username?.trim() || !password) {
|
if (!username?.trim() || !password) {
|
||||||
setError('Username and password are mandatory.');
|
setError(t('login.errorMandatory'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
setError(null);
|
setError(null);
|
||||||
@@ -45,12 +48,12 @@ export default function Login() {
|
|||||||
});
|
});
|
||||||
/* eslint-disable no-unused-vars */
|
/* eslint-disable no-unused-vars */
|
||||||
} catch (ignored) {
|
} catch (ignored) {
|
||||||
setError('Login unsuccessful. Please check your username and password.');
|
setError(t('login.errorInvalid'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await actions.user.getCurrentUser();
|
await actions.user.getCurrentUser();
|
||||||
navigate('/dashboard');
|
navigate(location.state?.from?.pathname || '/dashboard');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -67,7 +70,7 @@ export default function Login() {
|
|||||||
type="info"
|
type="info"
|
||||||
bordered
|
bordered
|
||||||
closeIcon={null}
|
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' }}
|
style={{ marginBottom: '1.5rem' }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -78,7 +81,7 @@ export default function Login() {
|
|||||||
<Input
|
<Input
|
||||||
size="large"
|
size="large"
|
||||||
prefix={<IconUser />}
|
prefix={<IconUser />}
|
||||||
placeholder="Username"
|
placeholder={t('login.usernamePlaceholder')}
|
||||||
value={username}
|
value={username}
|
||||||
showClear
|
showClear
|
||||||
autoFocus
|
autoFocus
|
||||||
@@ -97,7 +100,7 @@ export default function Login() {
|
|||||||
mode="password"
|
mode="password"
|
||||||
prefix={<IconLock />}
|
prefix={<IconLock />}
|
||||||
value={password}
|
value={password}
|
||||||
placeholder="Password"
|
placeholder={t('login.passwordPlaceholder')}
|
||||||
onChange={(value) => setPassword(value)}
|
onChange={(value) => setPassword(value)}
|
||||||
onKeyPress={async (e) => {
|
onKeyPress={async (e) => {
|
||||||
if (e.key === 'Enter') {
|
if (e.key === 'Enter') {
|
||||||
@@ -108,7 +111,7 @@ export default function Login() {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button block type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '1rem' }}>
|
<Button block type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '1rem' }}>
|
||||||
Login
|
{t('login.loginButton')}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -4,10 +4,13 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { Modal } from '@douyinfe/semi-ui-19';
|
import { Modal } from '@douyinfe/semi-ui-19';
|
||||||
|
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
const UserRemovalModal = function UserRemovalModal({ onOk, onCancel }) {
|
const UserRemovalModal = function UserRemovalModal({ onOk, onCancel }) {
|
||||||
|
const t = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Modal title="Removing user" visible={true} closable={false} onOk={onOk} onCancel={onCancel}>
|
<Modal title={t('users.removalModal.title')} visible={true} closable={false} onOk={onOk} onCancel={onCancel}>
|
||||||
<p>Removing this user will also remove all associated jobs.</p>
|
<p>{t('users.removalModal.message')}</p>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import { xhrDelete } from '../../services/xhr';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import Headline from '../../components/headline/Headline.jsx';
|
import Headline from '../../components/headline/Headline.jsx';
|
||||||
import './Users.less';
|
import './Users.less';
|
||||||
|
import { useTranslation } from '../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
const Users = function Users() {
|
const Users = function Users() {
|
||||||
|
const t = useTranslation();
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
const [loading, setLoading] = React.useState(true);
|
const [loading, setLoading] = React.useState(true);
|
||||||
const users = useSelector((state) => state.user.users);
|
const users = useSelector((state) => state.user.users);
|
||||||
@@ -32,7 +34,7 @@ const Users = function Users() {
|
|||||||
const onUserRemoval = async () => {
|
const onUserRemoval = async () => {
|
||||||
try {
|
try {
|
||||||
await xhrDelete('/api/admin/users', { userId: userIdToBeRemoved });
|
await xhrDelete('/api/admin/users', { userId: userIdToBeRemoved });
|
||||||
Toast.success('User successfully removed');
|
Toast.success(t('users.toastRemoved'));
|
||||||
setUserIdToBeRemoved(null);
|
setUserIdToBeRemoved(null);
|
||||||
await actions.jobsData.getJobs();
|
await actions.jobsData.getJobs();
|
||||||
await actions.user.getUsers();
|
await actions.user.getUsers();
|
||||||
@@ -45,10 +47,10 @@ const Users = function Users() {
|
|||||||
return (
|
return (
|
||||||
<div className="users">
|
<div className="users">
|
||||||
<Headline
|
<Headline
|
||||||
text="Users"
|
text={t('users.title')}
|
||||||
actions={
|
actions={
|
||||||
<Button type="primary" theme="solid" icon={<IconPlus />} onClick={() => navigate('/users/new')}>
|
<Button type="primary" theme="solid" icon={<IconPlus />} onClick={() => navigate('/users/new')}>
|
||||||
New User
|
{t('users.newUser')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -13,8 +13,10 @@ import './UserMutator.less';
|
|||||||
import { SegmentPart } from '../../../components/segment/SegmentPart';
|
import { SegmentPart } from '../../../components/segment/SegmentPart';
|
||||||
import { IconPlusCircle, IconArrowLeft } from '@douyinfe/semi-icons';
|
import { IconPlusCircle, IconArrowLeft } from '@douyinfe/semi-icons';
|
||||||
import Headline from '../../../components/headline/Headline.jsx';
|
import Headline from '../../../components/headline/Headline.jsx';
|
||||||
|
import { useTranslation } from '../../../services/i18n/i18n.jsx';
|
||||||
|
|
||||||
const UserMutator = function UserMutator() {
|
const UserMutator = function UserMutator() {
|
||||||
|
const t = useTranslation();
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const [username, setUsername] = React.useState('');
|
const [username, setUsername] = React.useState('');
|
||||||
const [password, setPassword] = React.useState('');
|
const [password, setPassword] = React.useState('');
|
||||||
@@ -55,7 +57,7 @@ const UserMutator = function UserMutator() {
|
|||||||
isAdmin,
|
isAdmin,
|
||||||
});
|
});
|
||||||
await actions.user.getUsers();
|
await actions.user.getUsers();
|
||||||
Toast.success('User successfully saved...');
|
Toast.success(t('users.mutation.saved'));
|
||||||
navigate('/users');
|
navigate('/users');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
@@ -66,7 +68,7 @@ const UserMutator = function UserMutator() {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<Headline
|
<Headline
|
||||||
text={params.userId ? 'Edit User' : 'New User'}
|
text={params.userId ? t('users.mutation.editTitle') : t('users.mutation.newTitle')}
|
||||||
actions={
|
actions={
|
||||||
<Button
|
<Button
|
||||||
icon={<IconArrowLeft />}
|
icon={<IconArrowLeft />}
|
||||||
@@ -74,17 +76,17 @@ const UserMutator = function UserMutator() {
|
|||||||
theme="borderless"
|
theme="borderless"
|
||||||
style={{ color: '#909090' }}
|
style={{ color: '#909090' }}
|
||||||
>
|
>
|
||||||
Back
|
{t('users.mutation.back')}
|
||||||
</Button>
|
</Button>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
<form className="userMutator">
|
<form className="userMutator">
|
||||||
<SegmentPart name="Username" helpText="The username used to login to Fredy">
|
<SegmentPart name={t('users.mutation.sectionUsername')} helpText={t('users.mutation.usernameHelp')}>
|
||||||
<Input
|
<Input
|
||||||
type="text"
|
type="text"
|
||||||
label="Username"
|
label={t('users.mutation.sectionUsername')}
|
||||||
maxLength={30}
|
maxLength={30}
|
||||||
placeholder="Username"
|
placeholder={t('users.mutation.usernamePlaceholder')}
|
||||||
autoFocus
|
autoFocus
|
||||||
width={6}
|
width={6}
|
||||||
value={username}
|
value={username}
|
||||||
@@ -92,38 +94,38 @@ const UserMutator = function UserMutator() {
|
|||||||
/>
|
/>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart name="Password" helpText="The password used to login to Fredy">
|
<SegmentPart name={t('users.mutation.sectionPassword')} helpText={t('users.mutation.passwordHelp')}>
|
||||||
<Input
|
<Input
|
||||||
mode="password"
|
mode="password"
|
||||||
label="Password"
|
label={t('users.mutation.sectionPassword')}
|
||||||
placeholder="Password"
|
placeholder={t('users.mutation.passwordPlaceholder')}
|
||||||
width={6}
|
width={6}
|
||||||
value={password}
|
value={password}
|
||||||
onChange={(val) => setPassword(val)}
|
onChange={(val) => setPassword(val)}
|
||||||
/>
|
/>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart name="Retype password" helpText="Retype the password to make sure they match">
|
<SegmentPart name={t('users.mutation.sectionRetypePassword')} helpText={t('users.mutation.retypePasswordHelp')}>
|
||||||
<Input
|
<Input
|
||||||
mode="password"
|
mode="password"
|
||||||
label="Retype password"
|
label={t('users.mutation.sectionRetypePassword')}
|
||||||
placeholder="Retype password"
|
placeholder={t('users.mutation.retypePasswordPlaceholder')}
|
||||||
width={6}
|
width={6}
|
||||||
value={password2}
|
value={password2}
|
||||||
onChange={(val) => setPassword2(val)}
|
onChange={(val) => setPassword2(val)}
|
||||||
/>
|
/>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<SegmentPart name="Is user an admin?" helpText="Check this if the user is an administrator">
|
<SegmentPart name={t('users.mutation.sectionIsAdmin')} helpText={t('users.mutation.isAdminHelp')}>
|
||||||
<Switch checked={isAdmin} onChange={(checked) => setIsAdmin(checked)} />
|
<Switch checked={isAdmin} onChange={(checked) => setIsAdmin(checked)} />
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
<Divider margin="1rem" />
|
<Divider margin="1rem" />
|
||||||
<div className="userMutator__actions">
|
<div className="userMutator__actions">
|
||||||
<Button size="small" theme="borderless" style={{ color: '#909090' }} onClick={() => navigate('/users')}>
|
<Button size="small" theme="borderless" style={{ color: '#909090' }} onClick={() => navigate('/users')}>
|
||||||
Cancel
|
{t('users.mutation.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="small" type="primary" theme="solid" icon={<IconPlusCircle />} onClick={saveUser}>
|
<Button size="small" type="primary" theme="solid" icon={<IconPlusCircle />} onClick={saveUser}>
|
||||||
Save
|
{t('users.mutation.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ import react from '@vitejs/plugin-react';
|
|||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from 'vite';
|
||||||
// https://vitejs.dev/config/
|
// https://vitejs.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
base: '',
|
// Must be absolute: with a relative base, asset URLs in index.html break on
|
||||||
|
// deep links like /listings/listing/:id (the SPA fallback serves index.html,
|
||||||
|
// but ./assets/* then resolves below the route path and loads HTML as JS).
|
||||||
|
base: '/',
|
||||||
build: {
|
build: {
|
||||||
chunkSizeWarningLimit: 9999999,
|
chunkSizeWarningLimit: 9999999,
|
||||||
outDir: './ui/public',
|
outDir: './ui/public',
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user