mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
acbaab05ed | ||
|
|
72fffc526b | ||
|
|
9e5989ece3 | ||
|
|
afc200c9e1 | ||
|
|
59226491f2 | ||
|
|
28f7760120 |
@@ -12,6 +12,7 @@ import { calculateDistanceForUser } from '../../services/geocoding/distanceServi
|
|||||||
import { fromJson } from '../../utils.js';
|
import { fromJson } from '../../utils.js';
|
||||||
import { trackFeature } from '../../services/tracking/Tracker.js';
|
import { trackFeature } from '../../services/tracking/Tracker.js';
|
||||||
import { FEATURES } from '../../features.js';
|
import { FEATURES } from '../../features.js';
|
||||||
|
import logger from '../../services/logger.js';
|
||||||
|
|
||||||
const service = restana();
|
const service = restana();
|
||||||
const userSettingsRouter = service.newRouter();
|
const userSettingsRouter = service.newRouter();
|
||||||
@@ -34,11 +35,12 @@ userSettingsRouter.get('/autocomplete', async (req, res) => {
|
|||||||
res.body = results;
|
res.body = results;
|
||||||
res.send();
|
res.send();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).send({ error: error.message });
|
res.statusCode = 500;
|
||||||
|
res.send({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
userSettingsRouter.post('/', async (req, res) => {
|
userSettingsRouter.post('/home-address', async (req, res) => {
|
||||||
const userId = req.session.currentUser;
|
const userId = req.session.currentUser;
|
||||||
const { home_address } = req.body;
|
const { home_address } = req.body;
|
||||||
|
|
||||||
@@ -51,15 +53,17 @@ userSettingsRouter.post('/', async (req, res) => {
|
|||||||
calculateDistanceForUser(userId);
|
calculateDistanceForUser(userId);
|
||||||
res.send({ success: true, coords });
|
res.send({ success: true, coords });
|
||||||
} else {
|
} else {
|
||||||
res.status(400).send({ error: 'Could not geocode address' });
|
res.statusCode = 400;
|
||||||
|
res.send({ error: 'Could not geocode address' });
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// If address is empty, maybe clear it?
|
|
||||||
upsertSettings({ home_address: null }, userId);
|
upsertSettings({ home_address: null }, userId);
|
||||||
res.send({ success: true });
|
res.send({ success: true });
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.status(500).send({ error: error.message });
|
logger.error('Error updating home address settings', error);
|
||||||
|
res.statusCode = 500;
|
||||||
|
res.send({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ export const getListingsKpisForJobIds = (jobIds = []) => {
|
|||||||
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) AS activeCount,
|
SUM(CASE WHEN is_active = 1 THEN 1 ELSE 0 END) AS activeCount,
|
||||||
AVG(price) AS avgPrice
|
AVG(price) AS avgPrice
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE job_id IN (${placeholders})`,
|
WHERE job_id IN (${placeholders})
|
||||||
|
AND manually_deleted = 0`,
|
||||||
jobIds,
|
jobIds,
|
||||||
)[0] || {};
|
)[0] || {};
|
||||||
|
|
||||||
@@ -80,6 +81,7 @@ export const getProviderDistributionForJobIds = (jobIds = []) => {
|
|||||||
`SELECT provider, COUNT(*) AS cnt
|
`SELECT provider, COUNT(*) AS cnt
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE job_id IN (${placeholders})
|
WHERE job_id IN (${placeholders})
|
||||||
|
AND manually_deleted = 0
|
||||||
GROUP BY provider
|
GROUP BY provider
|
||||||
ORDER BY cnt DESC`,
|
ORDER BY cnt DESC`,
|
||||||
jobIds,
|
jobIds,
|
||||||
@@ -118,8 +120,8 @@ export const getActiveOrUnknownListings = () => {
|
|||||||
return SqliteConnection.query(
|
return SqliteConnection.query(
|
||||||
`SELECT *
|
`SELECT *
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE is_active is null
|
WHERE (is_active is null OR is_active = 1)
|
||||||
OR is_active = 1
|
AND manually_deleted = 0
|
||||||
ORDER BY provider`,
|
ORDER BY provider`,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -306,6 +308,9 @@ export const queryListings = ({
|
|||||||
whereParts.push('(wl.id IS NULL)');
|
whereParts.push('(wl.id IS NULL)');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Build whereSql (filtering by manually_deleted = 0)
|
||||||
|
whereParts.push('(l.manually_deleted = 0)');
|
||||||
|
|
||||||
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
|
const whereSql = whereParts.length ? `WHERE ${whereParts.join(' AND ')}` : '';
|
||||||
const whereSqlWithAlias = whereSql
|
const whereSqlWithAlias = whereSql
|
||||||
.replace(/\btitle\b/g, 'l.title')
|
.replace(/\btitle\b/g, 'l.title')
|
||||||
@@ -370,8 +375,8 @@ export const queryListings = ({
|
|||||||
export const deleteListingsByJobId = (jobId) => {
|
export const deleteListingsByJobId = (jobId) => {
|
||||||
if (!jobId) return;
|
if (!jobId) return;
|
||||||
return SqliteConnection.execute(
|
return SqliteConnection.execute(
|
||||||
`DELETE
|
`UPDATE listings
|
||||||
FROM listings
|
SET manually_deleted = 1
|
||||||
WHERE job_id = @jobId`,
|
WHERE job_id = @jobId`,
|
||||||
{ jobId },
|
{ jobId },
|
||||||
);
|
);
|
||||||
@@ -387,9 +392,9 @@ export const deleteListingsById = (ids) => {
|
|||||||
if (!Array.isArray(ids) || ids.length === 0) return;
|
if (!Array.isArray(ids) || ids.length === 0) return;
|
||||||
const placeholders = ids.map(() => '?').join(',');
|
const placeholders = ids.map(() => '?').join(',');
|
||||||
return SqliteConnection.execute(
|
return SqliteConnection.execute(
|
||||||
`DELETE
|
`UPDATE listings
|
||||||
FROM listings
|
SET manually_deleted = 1
|
||||||
WHERE id IN (${placeholders})`,
|
WHERE id IN (${placeholders})`,
|
||||||
ids,
|
ids,
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -404,6 +409,7 @@ export const getListingsToGeocode = () => {
|
|||||||
`SELECT id, address
|
`SELECT id, address
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE is_active = 1
|
WHERE is_active = 1
|
||||||
|
AND manually_deleted = 0
|
||||||
AND address IS NOT NULL
|
AND address IS NOT NULL
|
||||||
AND (latitude IS NULL OR longitude IS NULL)`,
|
AND (latitude IS NULL OR longitude IS NULL)`,
|
||||||
);
|
);
|
||||||
@@ -443,6 +449,7 @@ export const getListingsForMap = ({ jobId, userId = null, isAdmin = false } = {}
|
|||||||
'l.latitude != -1',
|
'l.latitude != -1',
|
||||||
'l.longitude != -1',
|
'l.longitude != -1',
|
||||||
'l.is_active = 1',
|
'l.is_active = 1',
|
||||||
|
'l.manually_deleted = 0',
|
||||||
];
|
];
|
||||||
const params = { userId: userId || '__NO_USER__' };
|
const params = { userId: userId || '__NO_USER__' };
|
||||||
|
|
||||||
@@ -479,7 +486,7 @@ export const getListingsForMap = ({ jobId, userId = null, isAdmin = false } = {}
|
|||||||
* @returns {{title: string|null, address: string|null, price: number|null}[]}
|
* @returns {{title: string|null, address: string|null, price: number|null}[]}
|
||||||
*/
|
*/
|
||||||
export const getAllEntriesFromListings = () => {
|
export const getAllEntriesFromListings = () => {
|
||||||
return SqliteConnection.query(`SELECT title, address, price FROM listings`);
|
return SqliteConnection.query(`SELECT title, address, price FROM listings WHERE manually_deleted = 0`);
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -493,6 +500,7 @@ export const getGeocoordinatesByAddress = (address) => {
|
|||||||
`SELECT latitude, longitude
|
`SELECT latitude, longitude
|
||||||
FROM listings
|
FROM listings
|
||||||
WHERE address = @address
|
WHERE address = @address
|
||||||
|
AND manually_deleted = 0
|
||||||
AND latitude IS NOT NULL
|
AND latitude IS NOT NULL
|
||||||
AND longitude IS NOT NULL
|
AND longitude IS NOT NULL
|
||||||
AND latitude != -1
|
AND latitude != -1
|
||||||
@@ -515,6 +523,7 @@ export const getListingsToCalculateDistance = (jobId) => {
|
|||||||
FROM listings
|
FROM listings
|
||||||
WHERE job_id = @jobId
|
WHERE job_id = @jobId
|
||||||
AND is_active = 1
|
AND is_active = 1
|
||||||
|
AND manually_deleted = 0
|
||||||
AND latitude IS NOT NULL
|
AND latitude IS NOT NULL
|
||||||
AND longitude IS NOT NULL
|
AND longitude IS NOT NULL
|
||||||
AND distance_to_destination IS NULL`,
|
AND distance_to_destination IS NULL`,
|
||||||
@@ -535,6 +544,7 @@ export const getListingsForUserToCalculateDistance = (userId) => {
|
|||||||
JOIN jobs j ON l.job_id = j.id
|
JOIN jobs j ON l.job_id = j.id
|
||||||
WHERE j.user_id = @userId
|
WHERE j.user_id = @userId
|
||||||
AND l.is_active = 1
|
AND l.is_active = 1
|
||||||
|
AND l.manually_deleted = 0
|
||||||
AND l.latitude IS NOT NULL
|
AND l.latitude IS NOT NULL
|
||||||
AND l.longitude IS NOT NULL`,
|
AND l.longitude IS NOT NULL`,
|
||||||
{ userId },
|
{ userId },
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
/*
|
||||||
|
* Copyright (c) 2026 by Christian Kellner.
|
||||||
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
|
*/
|
||||||
|
|
||||||
|
export function up(db) {
|
||||||
|
// 1. Add manually_deleted column
|
||||||
|
db.exec(`ALTER TABLE listings ADD COLUMN manually_deleted INTEGER NOT NULL DEFAULT 0;`);
|
||||||
|
|
||||||
|
// 2. Remove change_set column
|
||||||
|
try {
|
||||||
|
db.exec(`ALTER TABLE listings DROP COLUMN change_set;`);
|
||||||
|
} catch {
|
||||||
|
// if column does not exists for whatever reason
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -90,15 +90,25 @@ export function upsertSettings(settingsMapOrEntry, userId = null) {
|
|||||||
: Object.entries(settingsMapOrEntry || {});
|
: Object.entries(settingsMapOrEntry || {});
|
||||||
|
|
||||||
for (const [name, rawValue] of entries) {
|
for (const [name, rawValue] of entries) {
|
||||||
const id = nanoid();
|
if (rawValue === null) {
|
||||||
const create_date = Date.now();
|
SqliteConnection.execute(
|
||||||
const json = toJson(rawValue);
|
`DELETE FROM settings WHERE name = @name AND (user_id = @userId OR (user_id IS NULL AND @userId IS NULL))`,
|
||||||
SqliteConnection.execute(
|
{
|
||||||
`INSERT INTO settings (id, create_date, name, value, user_id)
|
name,
|
||||||
|
userId,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
const id = nanoid();
|
||||||
|
const create_date = Date.now();
|
||||||
|
const json = toJson(rawValue);
|
||||||
|
SqliteConnection.execute(
|
||||||
|
`INSERT INTO settings (id, create_date, name, value, user_id)
|
||||||
VALUES (@id, @create_date, @name, @value, @userId)
|
VALUES (@id, @create_date, @name, @value, @userId)
|
||||||
ON CONFLICT(name, IFNULL(user_id, 'GLOBAL_SETTING')) DO UPDATE SET value = excluded.value`,
|
ON CONFLICT(name, IFNULL(user_id, 'GLOBAL_SETTING')) DO UPDATE SET value = excluded.value`,
|
||||||
{ id, create_date, name, value: json, userId },
|
{ id, create_date, name, value: json, userId },
|
||||||
);
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
// keep cache in sync (only for global settings)
|
// keep cache in sync (only for global settings)
|
||||||
if (userId == null) {
|
if (userId == null) {
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ function isOneOf(word, arr) {
|
|||||||
* @returns {boolean}
|
* @returns {boolean}
|
||||||
*/
|
*/
|
||||||
function nullOrEmpty(val) {
|
function nullOrEmpty(val) {
|
||||||
return val == null || val.length === 0 || val === 'null' || val === 'undefined';
|
return val == null || val.length === 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "19.1.1",
|
"version": "19.2.0",
|
||||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
|
|||||||
@@ -40,6 +40,8 @@ a:active {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
a {outline : none;}
|
||||||
|
|
||||||
.semi-icon:not(.semi-tabs-bar .semi-tabs-tab .semi-icon) {
|
.semi-icon:not(.semi-tabs-bar .semi-tabs-tab .semi-icon) {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -103,6 +103,10 @@ const ListingsGrid = () => {
|
|||||||
setPage(_page);
|
setPage(_page);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const cap = (val) => {
|
||||||
|
return String(val).charAt(0).toUpperCase() + String(val).slice(1);
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="listingsGrid">
|
<div className="listingsGrid">
|
||||||
<div className="listingsGrid__searchbar">
|
<div className="listingsGrid__searchbar">
|
||||||
@@ -251,11 +255,9 @@ const ListingsGrid = () => {
|
|||||||
bodyStyle={{ padding: '12px' }}
|
bodyStyle={{ padding: '12px' }}
|
||||||
>
|
>
|
||||||
<div className="listingsGrid__content">
|
<div className="listingsGrid__content">
|
||||||
<a href={item.url} target="_blank" rel="noopener noreferrer" className="listingsGrid__titleLink">
|
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
|
||||||
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
|
{cap(item.title)}
|
||||||
{item.title}
|
</Text>
|
||||||
</Text>
|
|
||||||
</a>
|
|
||||||
<Space vertical align="start" spacing={2} style={{ width: '100%', marginTop: 8 }}>
|
<Space vertical align="start" spacing={2} style={{ width: '100%', marginTop: 8 }}>
|
||||||
<Text type="secondary" icon={<IconCart />} size="small">
|
<Text type="secondary" icon={<IconCart />} size="small">
|
||||||
{item.price} €
|
{item.price} €
|
||||||
@@ -287,15 +289,11 @@ const ListingsGrid = () => {
|
|||||||
</Space>
|
</Space>
|
||||||
<Divider margin=".6rem" />
|
<Divider margin=".6rem" />
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||||
<Button
|
<div className="listingsGrid__linkButton">
|
||||||
title="Link to listing"
|
<a href={item.link} target="_blank" rel="noopener noreferrer">
|
||||||
type="primary"
|
<IconLink />
|
||||||
size="small"
|
</a>
|
||||||
onClick={async () => {
|
</div>
|
||||||
window.open(item.link);
|
|
||||||
}}
|
|
||||||
icon={<IconLink />}
|
|
||||||
/>
|
|
||||||
|
|
||||||
<Button
|
<Button
|
||||||
title="Remove"
|
title="Remove"
|
||||||
|
|||||||
@@ -103,4 +103,17 @@
|
|||||||
&__setupButton {
|
&__setupButton {
|
||||||
margin-bottom: 1rem;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&__linkButton {
|
||||||
|
background: var(--semi-color-fill-0);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
height: 24px;
|
||||||
|
width: 24px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,16 +4,21 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useEffect, useRef, useState } from 'react';
|
import React, { useEffect, useRef, useState } from 'react';
|
||||||
|
import { renderToString } from 'react-dom/server';
|
||||||
import maplibregl from 'maplibre-gl';
|
import maplibregl from 'maplibre-gl';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||||
import { useSelector, useActions } from '../../services/state/store.js';
|
import { useSelector, useActions } from '../../services/state/store.js';
|
||||||
import { distanceMeters, generateCircleCoords, getBoundsFromCenter } from './mapUtils.js';
|
import { distanceMeters, generateCircleCoords, getBoundsFromCenter, getBoundsFromCoords } from './mapUtils.js';
|
||||||
import { Select, Space, Typography, Button, Popover, Divider, Switch, Banner } from '@douyinfe/semi-ui-19';
|
import { Select, Space, Typography, Button, Popover, Divider, Switch, Banner, Toast } from '@douyinfe/semi-ui-19';
|
||||||
import { IconFilter } from '@douyinfe/semi-icons';
|
import { IconFilter, IconLink } from '@douyinfe/semi-icons';
|
||||||
|
import { IconDelete } from '@douyinfe/semi-icons';
|
||||||
|
|
||||||
import no_image from '../../assets/no_image.jpg';
|
import no_image from '../../assets/no_image.jpg';
|
||||||
import RangeSlider from 'react-range-slider-input';
|
import RangeSlider from 'react-range-slider-input';
|
||||||
import 'react-range-slider-input/dist/style.css';
|
import 'react-range-slider-input/dist/style.css';
|
||||||
import './Map.less';
|
import './Map.less';
|
||||||
|
import { xhrDelete } from '../../services/xhr.js';
|
||||||
|
import { Link } from 'react-router';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -97,6 +102,22 @@ export default function MapView() {
|
|||||||
return listings.filter((listing) => listing.price && listing.price >= min && listing.price <= max);
|
return listings.filter((listing) => listing.price && listing.price >= min && listing.price <= max);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
window.deleteListing = async (id) => {
|
||||||
|
try {
|
||||||
|
await xhrDelete('/api/listings/', { ids: [id] });
|
||||||
|
Toast.success('Listing successfully removed');
|
||||||
|
fetchListings();
|
||||||
|
} catch (error) {
|
||||||
|
Toast.error(error.message || 'Error deleting listing');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
delete window.deleteListing;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (map.current) return;
|
if (map.current) return;
|
||||||
|
|
||||||
@@ -227,26 +248,42 @@ export default function MapView() {
|
|||||||
}, [jobId]);
|
}, [jobId]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map.current || !homeAddress?.coords) return;
|
if (!map.current) return;
|
||||||
|
|
||||||
// We only want to zoom/fly when distanceFilter OR homeAddress actually change,
|
if (homeAddress?.coords) {
|
||||||
// not on every render. useEffect dependency array handles this.
|
// We only want to zoom/fly when distanceFilter OR homeAddress actually change,
|
||||||
if (distanceFilter > 0) {
|
// not on every render. useEffect dependency array handles this.
|
||||||
const bounds = getBoundsFromCenter([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter);
|
if (distanceFilter > 0) {
|
||||||
|
const bounds = getBoundsFromCenter([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter);
|
||||||
|
|
||||||
map.current.fitBounds(bounds, {
|
map.current.fitBounds(bounds, {
|
||||||
padding: 20,
|
padding: 20,
|
||||||
maxZoom: 15,
|
maxZoom: 15,
|
||||||
duration: 1000,
|
duration: 1000,
|
||||||
});
|
});
|
||||||
|
} else {
|
||||||
|
map.current.flyTo({
|
||||||
|
center: [homeAddress.coords.lng, homeAddress.coords.lat],
|
||||||
|
zoom: 12,
|
||||||
|
duration: 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
map.current.flyTo({
|
const filtered = filterListings();
|
||||||
center: [homeAddress.coords.lng, homeAddress.coords.lat],
|
const coords = filtered
|
||||||
zoom: 12,
|
.filter((l) => l.latitude != null && l.longitude != null && l.latitude !== -1 && l.longitude !== -1)
|
||||||
duration: 1000,
|
.map((l) => [l.longitude, l.latitude]);
|
||||||
});
|
|
||||||
|
if (coords.length > 0) {
|
||||||
|
const bounds = getBoundsFromCoords(coords);
|
||||||
|
map.current.fitBounds(bounds, {
|
||||||
|
padding: 50,
|
||||||
|
maxZoom: 15,
|
||||||
|
duration: 1000,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [homeAddress?.address, distanceFilter]);
|
}, [homeAddress?.address, distanceFilter, listings]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!map.current) return;
|
if (!map.current) return;
|
||||||
@@ -325,8 +362,8 @@ export default function MapView() {
|
|||||||
? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1)
|
? listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1)
|
||||||
: 'N/A';
|
: 'N/A';
|
||||||
|
|
||||||
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(
|
const popupContent = `
|
||||||
`<div class="map-popup-content">
|
<div class="map-popup-content">
|
||||||
<img src="${listing.image_url || no_image}" alt="${listing.title}" />
|
<img src="${listing.image_url || no_image}" alt="${listing.title}" />
|
||||||
<h4>${listing.title}</h4>
|
<h4>${listing.title}</h4>
|
||||||
<div class="info">
|
<div class="info">
|
||||||
@@ -334,10 +371,25 @@ export default function MapView() {
|
|||||||
<span><strong>Address:</strong> ${listing.address || 'N/A'}</span>
|
<span><strong>Address:</strong> ${listing.address || 'N/A'}</span>
|
||||||
<span><strong>Job:</strong> ${listing.job_name || 'N/A'}</span>
|
<span><strong>Job:</strong> ${listing.job_name || 'N/A'}</span>
|
||||||
<span><strong>Provider:</strong> ${capitalizedProvider}</span>
|
<span><strong>Provider:</strong> ${capitalizedProvider}</span>
|
||||||
<a href="${listing.link}" target="_blank" rel="noopener noreferrer">View Listing</a>
|
<span><strong>Size:</strong> ${listing.size != null ? `${listing.size} m²` : 'N/A'}</span>
|
||||||
|
<div style="display: flex; gap: 8px; margin-top: 8px; justify-content: space-between;">
|
||||||
|
<div class="map-popup-content__linkButton">
|
||||||
|
<a href="${listing.link}" target="_blank" rel="noopener noreferrer">
|
||||||
|
${renderToString(<IconLink />)}
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
class="map-popup-content__deleteButton"
|
||||||
|
title="Remove"
|
||||||
|
onclick="deleteListing('${listing.id}')"
|
||||||
|
>
|
||||||
|
${renderToString(<IconDelete />)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>`,
|
</div>`;
|
||||||
);
|
|
||||||
|
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(popupContent);
|
||||||
|
|
||||||
let color = '#3FB1CE'; // Default blue-ish
|
let color = '#3FB1CE'; // Default blue-ish
|
||||||
if (distanceFilter > 0 && homeAddress?.coords) {
|
if (distanceFilter > 0 && homeAddress?.coords) {
|
||||||
@@ -468,7 +520,12 @@ export default function MapView() {
|
|||||||
type="warning"
|
type="warning"
|
||||||
bordered
|
bordered
|
||||||
closeIcon={null}
|
closeIcon={null}
|
||||||
description="You have not set your home address yet. Please do so in the settings to use the distance filter."
|
description={
|
||||||
|
<span>
|
||||||
|
You have not set your home address yet. Please do so in the <Link to="/userSettings">user settings</Link>{' '}
|
||||||
|
to use the distance filter.
|
||||||
|
</span>
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -43,10 +43,62 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.info {
|
.info {
|
||||||
font-size: 0.9rem;
|
font-size: 0.8rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 0.2rem;
|
gap: 0.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&__linkButton {
|
||||||
|
background: var(--semi-color-primary);
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
text-decoration: none;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--semi-color-primary-hover);
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&__deleteButton {
|
||||||
|
background: var(--semi-color-danger);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
font-size: 14px;
|
||||||
|
height: 32px;
|
||||||
|
width: 32px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: var(--semi-color-danger-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
svg {
|
||||||
|
fill: currentColor;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -97,3 +97,34 @@ export const getBoundsFromCenter = (center, radiusInKm, padding = 0.15) => {
|
|||||||
[lng + offsetLng, lat + offsetLat],
|
[lng + offsetLng, lat + offsetLat],
|
||||||
];
|
];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculates the bounding box for a set of coordinates.
|
||||||
|
*
|
||||||
|
* @param {number[][]} coords - Array of [longitude, latitude] coordinates
|
||||||
|
* @param {number} [padding=0.1] - Padding to add to the bounds
|
||||||
|
* @returns {number[][]} Bounding box coordinates [[minLon, minLat], [maxLon, maxLat]]
|
||||||
|
*/
|
||||||
|
export const getBoundsFromCoords = (coords, padding = 0.1) => {
|
||||||
|
if (!coords || coords.length === 0) return null;
|
||||||
|
|
||||||
|
let minLng = Infinity;
|
||||||
|
let minLat = Infinity;
|
||||||
|
let maxLng = -Infinity;
|
||||||
|
let maxLat = -Infinity;
|
||||||
|
|
||||||
|
coords.forEach(([lng, lat]) => {
|
||||||
|
if (lng < minLng) minLng = lng;
|
||||||
|
if (lng > maxLng) maxLng = lng;
|
||||||
|
if (lat < minLat) minLat = lat;
|
||||||
|
if (lat > maxLat) maxLat = lat;
|
||||||
|
});
|
||||||
|
|
||||||
|
const lngDiff = maxLng - minLng;
|
||||||
|
const latDiff = maxLat - minLat;
|
||||||
|
|
||||||
|
return [
|
||||||
|
[minLng - lngDiff * padding, minLat - latDiff * padding],
|
||||||
|
[maxLng + lngDiff * padding, maxLat + latDiff * padding],
|
||||||
|
];
|
||||||
|
};
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ const UserSettings = () => {
|
|||||||
const handleSave = async () => {
|
const handleSave = async () => {
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
try {
|
try {
|
||||||
const response = await xhrPost('/api/user/settings', { home_address: address });
|
const response = await xhrPost('/api/user/settings/home-address', { home_address: address });
|
||||||
if (response.status === 200) {
|
if (response.status === 200) {
|
||||||
setCoords(response.json.coords);
|
setCoords(response.json.coords);
|
||||||
await actions.userSettings.getUserSettings();
|
await actions.userSettings.getUserSettings();
|
||||||
@@ -81,6 +81,7 @@ const UserSettings = () => {
|
|||||||
<AutoComplete
|
<AutoComplete
|
||||||
data={dataSource}
|
data={dataSource}
|
||||||
value={address}
|
value={address}
|
||||||
|
showClear
|
||||||
onChange={(v) => setAddress(v)}
|
onChange={(v) => setAddress(v)}
|
||||||
onSearch={searchAddress}
|
onSearch={searchAddress}
|
||||||
placeholder="Enter your home address"
|
placeholder="Enter your home address"
|
||||||
|
|||||||
Reference in New Issue
Block a user