mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
12 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
362166651d | ||
|
|
a020117a78 | ||
|
|
9207280ab4 | ||
|
|
94384df36d | ||
|
|
730cc52187 | ||
|
|
e82db5b6db | ||
|
|
2f8c021819 | ||
|
|
72c2c02e49 | ||
|
|
48c0360111 | ||
|
|
63c947896e | ||
|
|
2a814b6bb6 | ||
|
|
3249881771 |
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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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**.
|
||||||
|
|
||||||
|
|||||||
@@ -264,10 +264,12 @@ 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))
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -322,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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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} */
|
||||||
|
|||||||
@@ -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));
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -169,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,
|
||||||
@@ -186,7 +194,7 @@ export const getJobs = () => {
|
|||||||
j.last_run_at AS lastRunAt,
|
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) => ({
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
11
package.json
11
package.json
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "22.7.0",
|
"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",
|
||||||
@@ -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",
|
||||||
@@ -111,13 +112,13 @@
|
|||||||
"@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.4",
|
"prettier": "3.8.4",
|
||||||
|
|||||||
@@ -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('€');
|
||||||
expect(notify.size).toBeTypeOf('string');
|
// Size can legitimately be absent for a card whose layout shifts the
|
||||||
expect(notify.size).toContain('m²');
|
// 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).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('');
|
||||||
});
|
});
|
||||||
|
|||||||
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;
|
||||||
|
|
||||||
|
|||||||
@@ -185,6 +185,7 @@ const JobGrid = () => {
|
|||||||
await xhrPut(`/api/jobs/${jobId}/status`, { status });
|
await xhrPut(`/api/jobs/${jobId}/status`, { status });
|
||||||
Toast.success(t('jobs.toastStatusChanged'));
|
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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,9 +22,9 @@ import './ListingsGrid.less';
|
|||||||
import { useTranslation, useLocale } from '../../../services/i18n/i18n.jsx';
|
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 t = useTranslation();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
return (
|
return (
|
||||||
@@ -126,18 +126,38 @@ const ListingsGrid = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content={t('listings.tooltipRemove')}>
|
{isHiddenView ? (
|
||||||
<Button
|
<Tooltip content={t('listings.tooltipUndelete')}>
|
||||||
size="small"
|
<Button
|
||||||
icon={<IconDelete />}
|
size="small"
|
||||||
style={{ color: '#fb7185' }}
|
icon={
|
||||||
theme="borderless"
|
<span className="listingsGrid__strike" aria-hidden="true">
|
||||||
onClick={(e) => {
|
<IconDelete />
|
||||||
e.stopPropagation();
|
</span>
|
||||||
onDelete(item.id);
|
}
|
||||||
}}
|
style={{ color: '#34d399' }}
|
||||||
/>
|
theme="borderless"
|
||||||
</Tooltip>
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRestore?.(item.id);
|
||||||
|
}}
|
||||||
|
aria-label={t('listings.tooltipUndelete')}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Tooltip content={t('listings.tooltipRemove')}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<IconDelete />}
|
||||||
|
style={{ color: '#fb7185' }}
|
||||||
|
theme="borderless"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(item.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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';
|
||||||
@@ -50,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;
|
||||||
|
|
||||||
@@ -66,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,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -85,6 +100,7 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
|||||||
jobNameFilter,
|
jobNameFilter,
|
||||||
watchListFilter,
|
watchListFilter,
|
||||||
statusFilter,
|
statusFilter,
|
||||||
|
hiddenOnly,
|
||||||
isWatchlistMode,
|
isWatchlistMode,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
@@ -138,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 {
|
||||||
@@ -158,118 +188,161 @@ 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">
|
||||||
<Input
|
<Tooltip content={t('listings.filterSearchHelp')} trigger="hover" position="top">
|
||||||
className="listingsOverview__topbar__search"
|
<span className="listingsOverview__topbar__tooltipWrap listingsOverview__topbar__search">
|
||||||
prefix={<IconSearch />}
|
<Input
|
||||||
showClear
|
prefix={<IconSearch />}
|
||||||
placeholder={t('listings.searchPlaceholder')}
|
showClear
|
||||||
defaultValue={freeTextFilter ?? ''}
|
placeholder={t('listings.searchPlaceholder')}
|
||||||
onChange={handleFilterChange}
|
defaultValue={freeTextFilter ?? ''}
|
||||||
/>
|
onChange={handleFilterChange}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<RadioGroup
|
<Tooltip content={t('listings.filterActivityHelp')} trigger="hover" position="top">
|
||||||
type="button"
|
<span className="listingsOverview__topbar__tooltipWrap">
|
||||||
buttonSize="middle"
|
<RadioGroup
|
||||||
value={activityFilter === null ? 'all' : String(activityFilter)}
|
type="button"
|
||||||
onChange={(e) => {
|
buttonSize="middle"
|
||||||
const v = e.target.value;
|
value={activityRadioValue}
|
||||||
setActivityFilter(v === 'all' ? null : v === 'true');
|
onChange={(e) => {
|
||||||
setPage(1);
|
const v = e.target.value;
|
||||||
}}
|
if (v === 'hidden') {
|
||||||
>
|
setHiddenOnly(true);
|
||||||
<Radio value="all">{t('listings.filterAll')}</Radio>
|
setActivityFilter(null);
|
||||||
<Radio value="true">{t('listings.filterActive')}</Radio>
|
} else {
|
||||||
<Radio value="false">{t('listings.filterInactive')}</Radio>
|
setHiddenOnly(false);
|
||||||
</RadioGroup>
|
setActivityFilter(v === 'all' ? null : v === 'true');
|
||||||
|
}
|
||||||
|
setPage(1);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Radio value="all">{t('listings.filterAll')}</Radio>
|
||||||
|
<Radio value="true">{t('listings.filterActive')}</Radio>
|
||||||
|
<Radio value="false">{t('listings.filterInactive')}</Radio>
|
||||||
|
<Radio value="hidden">{t('listings.filterHidden')}</Radio>
|
||||||
|
</RadioGroup>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
{!isWatchlistMode && (
|
{!isWatchlistMode && (
|
||||||
<RadioGroup
|
<Tooltip content={t('listings.filterWatchHelp')} trigger="hover" position="top">
|
||||||
type="button"
|
<span className="listingsOverview__topbar__tooltipWrap">
|
||||||
buttonSize="middle"
|
<RadioGroup
|
||||||
value={watchListFilter === null ? 'all' : String(watchListFilter)}
|
type="button"
|
||||||
onChange={(e) => {
|
buttonSize="middle"
|
||||||
const v = e.target.value;
|
value={watchListFilter === null ? 'all' : String(watchListFilter)}
|
||||||
setWatchListFilter(v === 'all' ? null : v === 'true');
|
onChange={(e) => {
|
||||||
setPage(1);
|
const v = e.target.value;
|
||||||
}}
|
setWatchListFilter(v === 'all' ? null : v === 'true');
|
||||||
>
|
setPage(1);
|
||||||
<Radio value="all">{t('listings.filterAll')}</Radio>
|
}}
|
||||||
<Radio value="true">{t('listings.filterWatched')}</Radio>
|
>
|
||||||
<Radio value="false">{t('listings.filterUnwatched')}</Radio>
|
<Radio value="all">{t('listings.filterAll')}</Radio>
|
||||||
</RadioGroup>
|
<Radio value="true">{t('listings.filterWatched')}</Radio>
|
||||||
|
<Radio value="false">{t('listings.filterUnwatched')}</Radio>
|
||||||
|
</RadioGroup>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Select
|
<Tooltip content={t('listings.filterStatusHelp')} trigger="hover" position="top">
|
||||||
placeholder={t('listings.filterStatusPlaceholder')}
|
<span className="listingsOverview__topbar__tooltipWrap">
|
||||||
showClear
|
<Select
|
||||||
onChange={(val) => {
|
placeholder={t('listings.filterStatusPlaceholder')}
|
||||||
setStatusFilter(val ?? null);
|
showClear
|
||||||
setPage(1);
|
onChange={(val) => {
|
||||||
}}
|
setStatusFilter(val ?? null);
|
||||||
value={statusFilter}
|
setPage(1);
|
||||||
style={{ width: 150 }}
|
}}
|
||||||
>
|
value={statusFilter}
|
||||||
<Select.Option value="applied">{t('listings.filterStatusApplied')}</Select.Option>
|
style={{ width: 150 }}
|
||||||
<Select.Option value="rejected">{t('listings.filterStatusRejected')}</Select.Option>
|
>
|
||||||
<Select.Option value="accepted">{t('listings.filterStatusAccepted')}</Select.Option>
|
<Select.Option value="applied">{t('listings.filterStatusApplied')}</Select.Option>
|
||||||
<Select.Option value="none">{t('listings.filterStatusNone')}</Select.Option>
|
<Select.Option value="rejected">{t('listings.filterStatusRejected')}</Select.Option>
|
||||||
</Select>
|
<Select.Option value="accepted">{t('listings.filterStatusAccepted')}</Select.Option>
|
||||||
|
<Select.Option value="none">{t('listings.filterStatusNone')}</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Select
|
<Tooltip content={t('listings.filterProviderHelp')} trigger="hover" position="top">
|
||||||
placeholder={t('listings.filterProviderPlaceholder')}
|
<span className="listingsOverview__topbar__tooltipWrap">
|
||||||
showClear
|
<Select
|
||||||
onChange={(val) => {
|
placeholder={t('listings.filterProviderPlaceholder')}
|
||||||
setProviderFilter(val);
|
showClear
|
||||||
setPage(1);
|
onChange={(val) => {
|
||||||
}}
|
setProviderFilter(val);
|
||||||
value={providerFilter}
|
setPage(1);
|
||||||
style={{ width: 130 }}
|
}}
|
||||||
>
|
value={providerFilter}
|
||||||
{providers?.map((p) => (
|
style={{ width: 130 }}
|
||||||
<Select.Option key={p.id} value={p.id}>
|
>
|
||||||
{p.name}
|
{providers?.map((p) => (
|
||||||
</Select.Option>
|
<Select.Option key={p.id} value={p.id}>
|
||||||
))}
|
{p.name}
|
||||||
</Select>
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Select
|
<Tooltip content={t('listings.filterJobHelp')} trigger="hover" position="top">
|
||||||
placeholder={t('listings.filterJobPlaceholder')}
|
<span className="listingsOverview__topbar__tooltipWrap">
|
||||||
showClear
|
<Select
|
||||||
onChange={(val) => {
|
placeholder={t('listings.filterJobPlaceholder')}
|
||||||
setJobNameFilter(val);
|
showClear
|
||||||
setPage(1);
|
onChange={(val) => {
|
||||||
}}
|
setJobNameFilter(val);
|
||||||
value={jobNameFilter}
|
setPage(1);
|
||||||
style={{ width: 130 }}
|
}}
|
||||||
>
|
value={jobNameFilter}
|
||||||
{jobs?.map((j) => (
|
style={{ width: 130 }}
|
||||||
<Select.Option key={j.id} value={j.id}>
|
>
|
||||||
{j.name}
|
{jobs?.map((j) => (
|
||||||
</Select.Option>
|
<Select.Option key={j.id} value={j.id}>
|
||||||
))}
|
{j.name}
|
||||||
</Select>
|
</Select.Option>
|
||||||
|
))}
|
||||||
|
</Select>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Select
|
<Tooltip content={t('listings.filterSortHelp')} trigger="hover" position="top">
|
||||||
prefix={t('listings.sortPrefix')}
|
<span className="listingsOverview__topbar__tooltipWrap listingsOverview__topbar__sort">
|
||||||
className="listingsOverview__topbar__sort"
|
<Select
|
||||||
style={{ width: 220 }}
|
prefix={t('listings.sortPrefix')}
|
||||||
value={sortField}
|
style={{ width: 220 }}
|
||||||
onChange={(val) => setSortField(val)}
|
value={sortField}
|
||||||
>
|
onChange={(val) => setSortField(val)}
|
||||||
<Select.Option value="job_name">{t('listings.sortByJobName')}</Select.Option>
|
>
|
||||||
<Select.Option value="created_at">{t('listings.sortByDate')}</Select.Option>
|
<Select.Option value="job_name">{t('listings.sortByJobName')}</Select.Option>
|
||||||
<Select.Option value="price">{t('listings.sortByPrice')}</Select.Option>
|
<Select.Option value="created_at">{t('listings.sortByDate')}</Select.Option>
|
||||||
<Select.Option value="provider">{t('listings.sortByProvider')}</Select.Option>
|
<Select.Option value="price">{t('listings.sortByPrice')}</Select.Option>
|
||||||
</Select>
|
<Select.Option value="provider">{t('listings.sortByProvider')}</Select.Option>
|
||||||
|
</Select>
|
||||||
|
</span>
|
||||||
|
</Tooltip>
|
||||||
|
|
||||||
<Button
|
<Tooltip
|
||||||
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
|
content={sortDir === 'asc' ? t('listings.sortAscending') : t('listings.sortDescending')}
|
||||||
onClick={() => setSortDir(sortDir === 'asc' ? 'desc' : 'asc')}
|
trigger="hover"
|
||||||
title={sortDir === 'asc' ? t('listings.sortAscending') : t('listings.sortDescending')}
|
position="top"
|
||||||
/>
|
>
|
||||||
|
<span className="listingsOverview__topbar__tooltipWrap">
|
||||||
|
<Button
|
||||||
|
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
|
||||||
|
onClick={() => setSortDir(sortDir === 'asc' ? 'desc' : 'asc')}
|
||||||
|
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={t('listings.tooltipGridView')}>
|
<Tooltip content={t('listings.tooltipGridView')}>
|
||||||
@@ -293,6 +366,16 @@ const ListingsOverview = ({ mode = 'all' }) => {
|
|||||||
</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 />}
|
||||||
@@ -307,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}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
@@ -315,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;
|
||||||
|
|||||||
@@ -92,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">
|
||||||
|
|||||||
@@ -22,9 +22,17 @@ import './ListingsTable.less';
|
|||||||
import { useTranslation, useLocale } from '../../services/i18n/i18n.jsx';
|
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 t = useTranslation();
|
||||||
const locale = useLocale();
|
const locale = useLocale();
|
||||||
return (
|
return (
|
||||||
@@ -123,18 +131,38 @@ const ListingsTable = ({ listings, onWatch, onNavigate, onDelete, onStatusChange
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
<Tooltip content={t('listings.tooltipRemove')}>
|
{isHiddenView ? (
|
||||||
<Button
|
<Tooltip content={t('listings.tooltipUndelete')}>
|
||||||
size="small"
|
<Button
|
||||||
icon={<IconDelete />}
|
size="small"
|
||||||
style={{ color: '#fb7185' }}
|
icon={
|
||||||
theme="borderless"
|
<span className="listingsTable__strike" aria-hidden="true">
|
||||||
onClick={(e) => {
|
<IconDelete />
|
||||||
e.stopPropagation();
|
</span>
|
||||||
onDelete(item.id);
|
}
|
||||||
}}
|
style={{ color: '#34d399' }}
|
||||||
/>
|
theme="borderless"
|
||||||
</Tooltip>
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRestore?.(item.id);
|
||||||
|
}}
|
||||||
|
aria-label={t('listings.tooltipUndelete')}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
) : (
|
||||||
|
<Tooltip content={t('listings.tooltipRemove')}>
|
||||||
|
<Button
|
||||||
|
size="small"
|
||||||
|
icon={<IconDelete />}
|
||||||
|
style={{ color: '#fb7185' }}
|
||||||
|
theme="borderless"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onDelete(item.id);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -135,6 +135,7 @@
|
|||||||
"listings.filterAll": "Alle",
|
"listings.filterAll": "Alle",
|
||||||
"listings.filterActive": "Aktiv",
|
"listings.filterActive": "Aktiv",
|
||||||
"listings.filterInactive": "Inaktiv",
|
"listings.filterInactive": "Inaktiv",
|
||||||
|
"listings.filterHidden": "Versteckt",
|
||||||
"listings.filterWatched": "Beobachtet",
|
"listings.filterWatched": "Beobachtet",
|
||||||
"listings.filterUnwatched": "Nicht beobachtet",
|
"listings.filterUnwatched": "Nicht beobachtet",
|
||||||
"listings.filterStatusPlaceholder": "Status",
|
"listings.filterStatusPlaceholder": "Status",
|
||||||
@@ -144,6 +145,17 @@
|
|||||||
"listings.filterStatusNone": "Kein Status",
|
"listings.filterStatusNone": "Kein Status",
|
||||||
"listings.filterProviderPlaceholder": "Anbieter",
|
"listings.filterProviderPlaceholder": "Anbieter",
|
||||||
"listings.filterJobPlaceholder": "Job",
|
"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.sortByJobName": "Job-Name",
|
||||||
"listings.sortByDate": "Inserat-Datum",
|
"listings.sortByDate": "Inserat-Datum",
|
||||||
"listings.sortByPrice": "Preis",
|
"listings.sortByPrice": "Preis",
|
||||||
|
|||||||
@@ -135,6 +135,7 @@
|
|||||||
"listings.filterAll": "All",
|
"listings.filterAll": "All",
|
||||||
"listings.filterActive": "Active",
|
"listings.filterActive": "Active",
|
||||||
"listings.filterInactive": "Inactive",
|
"listings.filterInactive": "Inactive",
|
||||||
|
"listings.filterHidden": "Hidden",
|
||||||
"listings.filterWatched": "Watched",
|
"listings.filterWatched": "Watched",
|
||||||
"listings.filterUnwatched": "Unwatched",
|
"listings.filterUnwatched": "Unwatched",
|
||||||
"listings.filterStatusPlaceholder": "Status",
|
"listings.filterStatusPlaceholder": "Status",
|
||||||
@@ -144,6 +145,17 @@
|
|||||||
"listings.filterStatusNone": "No status",
|
"listings.filterStatusNone": "No status",
|
||||||
"listings.filterProviderPlaceholder": "Provider",
|
"listings.filterProviderPlaceholder": "Provider",
|
||||||
"listings.filterJobPlaceholder": "Job",
|
"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.sortByJobName": "Job Name",
|
||||||
"listings.sortByDate": "Listing Date",
|
"listings.sortByDate": "Listing Date",
|
||||||
"listings.sortByPrice": "Price",
|
"listings.sortByPrice": "Price",
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
@@ -255,11 +255,11 @@ 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(t('settings.toastSaveError'));
|
const serverReason = exception?.json?.error ?? exception?.json?.message;
|
||||||
}
|
Toast.error(serverReason ?? t('settings.toastSaveError'));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
Toast.success(t('settings.toastSavedReloading'));
|
Toast.success(t('settings.toastSavedReloading'));
|
||||||
|
|||||||
@@ -189,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);
|
||||||
@@ -196,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 {
|
||||||
@@ -216,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,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
yarn.lock
47
yarn.lock
@@ -2636,10 +2636,10 @@ baseline-browser-mapping@^2.9.0:
|
|||||||
resolved "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz"
|
resolved "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.9.19.tgz"
|
||||||
integrity sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==
|
integrity sha512-ipDqC8FrAl/76p2SSWKSI+H9tFwm7vYqXQrItCuiVPt26Km0jS+NzSsBWAaBusvSbQcfJG+JitdMm+wZAgTYqg==
|
||||||
|
|
||||||
better-sqlite3@^12.10.0:
|
better-sqlite3@^12.10.1:
|
||||||
version "12.10.0"
|
version "12.10.1"
|
||||||
resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.10.0.tgz#bde622d14a18008583a53bc53501ae98f1a12221"
|
resolved "https://registry.yarnpkg.com/better-sqlite3/-/better-sqlite3-12.10.1.tgz#1fedf77460210c83d5140fb700c81700964a1a24"
|
||||||
integrity sha512-CyzaZRQKyHkB2ZInfTTl2nvT33EbDpjkLEbE8/Zck3Ll6O0qqvuGdrJ45HgtH+HykRg88ITY3AdreBGN70aBSQ==
|
integrity sha512-HfFtzCqnSfwB3+HroF6PSKzyh+7RfNMGPCzHFUZXRlvrPCb4P3cvxKZNN43Sr7IrkofqQZM+gIvffGpA8VvqgA==
|
||||||
dependencies:
|
dependencies:
|
||||||
bindings "^1.5.0"
|
bindings "^1.5.0"
|
||||||
prebuild-install "^7.1.1"
|
prebuild-install "^7.1.1"
|
||||||
@@ -3560,10 +3560,10 @@ eslint-visitor-keys@^5.0.1:
|
|||||||
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
|
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
|
||||||
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
|
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
|
||||||
|
|
||||||
eslint@10.4.1:
|
eslint@10.5.0:
|
||||||
version "10.4.1"
|
version "10.5.0"
|
||||||
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.4.1.tgz#f6640b176e0912246d9ddbf8fcfa5e8b7f02445a"
|
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.5.0.tgz#5fca69d6b41fe7e00ba22d4100b2e44efe439ad5"
|
||||||
integrity sha512-AyIKhnOBuOAdueD7RB3xB+YeAWScb9jHsJBgH2Hcde8InP5JYhqrRR6iTMHyTEwgENK54Cp44e4v8BwNhsuHuw==
|
integrity sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==
|
||||||
dependencies:
|
dependencies:
|
||||||
"@eslint-community/eslint-utils" "^4.8.0"
|
"@eslint-community/eslint-utils" "^4.8.0"
|
||||||
"@eslint-community/regexpp" "^4.12.2"
|
"@eslint-community/regexpp" "^4.12.2"
|
||||||
@@ -4749,10 +4749,10 @@ keyv@^4.5.4:
|
|||||||
dependencies:
|
dependencies:
|
||||||
json-buffer "3.0.1"
|
json-buffer "3.0.1"
|
||||||
|
|
||||||
less@4.6.4:
|
less@4.6.6:
|
||||||
version "4.6.4"
|
version "4.6.6"
|
||||||
resolved "https://registry.yarnpkg.com/less/-/less-4.6.4.tgz#3ff8068e6c8a59f1ece8a6b9227bda28c1ed68a2"
|
resolved "https://registry.yarnpkg.com/less/-/less-4.6.6.tgz#f7854302a3389d2daf96fb3444ba80a54436e66e"
|
||||||
integrity sha512-OJmO5+HxZLLw0RLzkqaNHzcgEAQG7C0y3aMbwtCzIUFZsLMNNq/1IdAdHEycQ58CwUO3jPTHmoN+tE5I7FQxNg==
|
integrity sha512-ooPSwQGQ2sVe8Dh1jVsbKKsRR2gd8lFK72BDkeSzjnD1T5aIHL65hCMfO0GVmtriKgDKrQv6xp9UrihUsWuAzA==
|
||||||
dependencies:
|
dependencies:
|
||||||
copy-anything "^3.0.5"
|
copy-anything "^3.0.5"
|
||||||
parse-node-version "^1.0.1"
|
parse-node-version "^1.0.1"
|
||||||
@@ -4760,7 +4760,7 @@ less@4.6.4:
|
|||||||
errno "^0.1.1"
|
errno "^0.1.1"
|
||||||
graceful-fs "^4.1.2"
|
graceful-fs "^4.1.2"
|
||||||
image-size "~0.5.0"
|
image-size "~0.5.0"
|
||||||
make-dir "^2.1.0"
|
make-dir "^5.1.0"
|
||||||
mime "^1.4.1"
|
mime "^1.4.1"
|
||||||
needle "^3.1.0"
|
needle "^3.1.0"
|
||||||
source-map "~0.6.0"
|
source-map "~0.6.0"
|
||||||
@@ -4955,13 +4955,10 @@ magic-string@^0.30.21:
|
|||||||
dependencies:
|
dependencies:
|
||||||
"@jridgewell/sourcemap-codec" "^1.5.5"
|
"@jridgewell/sourcemap-codec" "^1.5.5"
|
||||||
|
|
||||||
make-dir@^2.1.0:
|
make-dir@^5.1.0:
|
||||||
version "2.1.0"
|
version "5.1.0"
|
||||||
resolved "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz"
|
resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-5.1.0.tgz#59b2d9acf7ffa543d14238617a697458fa8dd5c9"
|
||||||
integrity sha512-LS9X+dc8KLxXCb8dni79fLIIUA5VyZoyjSMCwTluaXA0o27cCK0bhXkpgw+sTXVpPy/lSO57ilRixqk0vDmtRA==
|
integrity sha512-IfpFq6UM39dUNiphpA6uDezNx/AvWyhwfICWPR3t1VspkgkMZrL+Rk1RbN1bx+aeNYwOrqGJgEgV3yotk+ZUVw==
|
||||||
dependencies:
|
|
||||||
pify "^4.0.1"
|
|
||||||
semver "^5.6.0"
|
|
||||||
|
|
||||||
maplibre-gl@^5.24.0:
|
maplibre-gl@^5.24.0:
|
||||||
version "5.24.0"
|
version "5.24.0"
|
||||||
@@ -6078,11 +6075,6 @@ picomatch@^4.0.4:
|
|||||||
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
|
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589"
|
||||||
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
|
integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==
|
||||||
|
|
||||||
pify@^4.0.1:
|
|
||||||
version "4.0.1"
|
|
||||||
resolved "https://registry.npmjs.org/pify/-/pify-4.0.1.tgz"
|
|
||||||
integrity sha512-uB80kBFb/tfd68bVleG9T5GGsGPjJrLAUpR5PZIrhBnIaRTQRjqdJSsIKkOP6OAIFbj7GOrcudc5pNjZ+geV2g==
|
|
||||||
|
|
||||||
pino-abstract-transport@^3.0.0:
|
pino-abstract-transport@^3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz#b21e5f33a297e8c4c915c62b3ce5dd4a87a52c23"
|
resolved "https://registry.yarnpkg.com/pino-abstract-transport/-/pino-abstract-transport-3.0.0.tgz#b21e5f33a297e8c4c915c62b3ce5dd4a87a52c23"
|
||||||
@@ -6954,11 +6946,6 @@ secure-json-parse@^4.0.0:
|
|||||||
resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-4.1.0.tgz#4f1ab41c67a13497ea1b9131bb4183a22865477c"
|
resolved "https://registry.yarnpkg.com/secure-json-parse/-/secure-json-parse-4.1.0.tgz#4f1ab41c67a13497ea1b9131bb4183a22865477c"
|
||||||
integrity sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==
|
integrity sha512-l4KnYfEyqYJxDwlNVyRfO2E4NTHfMKAWdUuA8J0yve2Dz/E/PdBepY03RvyJpssIpRFwJoCD55wA+mEDs6ByWA==
|
||||||
|
|
||||||
semver@^5.6.0:
|
|
||||||
version "5.7.2"
|
|
||||||
resolved "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz"
|
|
||||||
integrity sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==
|
|
||||||
|
|
||||||
semver@^6.3.1:
|
semver@^6.3.1:
|
||||||
version "6.3.1"
|
version "6.3.1"
|
||||||
resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz"
|
resolved "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz"
|
||||||
|
|||||||
Reference in New Issue
Block a user