mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2bcec04d55 | ||
|
|
ee2112a24d | ||
|
|
5a54448288 | ||
|
|
f1b8709ab7 |
@@ -151,4 +151,28 @@ export default async function userSettingsPlugin(fastify) {
|
|||||||
return reply.code(500).send({ error: error.message });
|
return reply.code(500).send({ error: error.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
fastify.post('/listing-deletion-preference', async (request, reply) => {
|
||||||
|
const userId = request.session.currentUser;
|
||||||
|
const { listing_deletion_preference } = request.body;
|
||||||
|
|
||||||
|
const globalSettings = await getSettings();
|
||||||
|
if (globalSettings.demoMode && !isAdmin(request)) {
|
||||||
|
return reply.code(403).send({ error: 'In demo mode, it is not allowed to change settings.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (listing_deletion_preference == null) {
|
||||||
|
return reply.code(400).send({ error: 'listing_deletion_preference is required.' });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { skipPrompt, hardDelete } = listing_deletion_preference;
|
||||||
|
|
||||||
|
try {
|
||||||
|
upsertSettings({ listing_deletion_preference: { skipPrompt, hardDelete } }, userId);
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Error updating listing deletion preference', error);
|
||||||
|
return reply.code(500).send({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "22.2.0",
|
"version": "22.2.1",
|
||||||
"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",
|
||||||
|
|||||||
@@ -38,6 +38,20 @@ async function tryReadFile(filepath) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function withRealEstateType(data, realEstateType) {
|
||||||
|
if (!realEstateType?.length || !Array.isArray(data?.resultListItems)) {
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cloned = typeof structuredClone === 'function' ? structuredClone(data) : JSON.parse(JSON.stringify(data));
|
||||||
|
for (const item of cloned.resultListItems) {
|
||||||
|
if (item?.type === 'EXPOSE_RESULT' && item?.item) {
|
||||||
|
item.item.realEstateType = realEstateType;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cloned;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Returns fixture HTML for the given URL by mapping hostname → provider name,
|
* Returns fixture HTML for the given URL by mapping hostname → provider name,
|
||||||
* then distinguishing list vs detail pages by comparing the URL path against
|
* then distinguishing list vs detail pages by comparing the URL path against
|
||||||
@@ -83,7 +97,10 @@ export function buildFetchMock() {
|
|||||||
const raw = await tryReadFile(path.join(FIXTURES_DIR, 'immoscout_list.json'));
|
const raw = await tryReadFile(path.join(FIXTURES_DIR, 'immoscout_list.json'));
|
||||||
listData = raw ? JSON.parse(raw) : { resultListItems: [] };
|
listData = raw ? JSON.parse(raw) : { resultListItems: [] };
|
||||||
}
|
}
|
||||||
return { ok: true, status: 200, json: () => Promise.resolve(listData) };
|
|
||||||
|
const requestedType = new URL(urlStr).searchParams.get('realestatetype');
|
||||||
|
const responseData = withRealEstateType(listData, requestedType);
|
||||||
|
return { ok: true, status: 200, json: () => Promise.resolve(responseData) };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (urlStr.includes('api.mobile.immobilienscout24.de/expose/')) {
|
if (urlStr.includes('api.mobile.immobilienscout24.de/expose/')) {
|
||||||
|
|||||||
@@ -4,11 +4,16 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { convertWebToMobile } from '../../../lib/services/immoscout/immoscout-web-translator.js';
|
import { convertWebToMobile } from '../../../lib/services/immoscout/immoscout-web-translator.js';
|
||||||
import { expect } from 'vitest';
|
import { expect, vi } from 'vitest';
|
||||||
import { readFile } from 'fs/promises';
|
import { readFile } from 'fs/promises';
|
||||||
|
import { buildFetchMock } from '../../offlineFixtures.js';
|
||||||
|
|
||||||
export const testData = JSON.parse(await readFile(new URL('./testdata.json', import.meta.url)));
|
export const testData = JSON.parse(await readFile(new URL('./testdata.json', import.meta.url)));
|
||||||
|
|
||||||
|
if (process.env.TEST_MODE === 'offline') {
|
||||||
|
vi.stubGlobal('fetch', buildFetchMock());
|
||||||
|
}
|
||||||
|
|
||||||
describe('#immoscout-mobile URL conversion', () => {
|
describe('#immoscout-mobile URL conversion', () => {
|
||||||
// Test shape URL conversion
|
// Test shape URL conversion
|
||||||
it('should convert a full web URL with shape to mobile URL', () => {
|
it('should convert a full web URL with shape to mobile URL', () => {
|
||||||
|
|||||||
@@ -155,6 +155,7 @@ const routes = {
|
|||||||
'GET /api/dashboard': dashboard,
|
'GET /api/dashboard': dashboard,
|
||||||
'GET /api/demo': { demoMode: false },
|
'GET /api/demo': { demoMode: false },
|
||||||
'POST /api/user/settings/news-hash': {},
|
'POST /api/user/settings/news-hash': {},
|
||||||
|
'POST /api/user/settings/listing-deletion-preference': {},
|
||||||
};
|
};
|
||||||
|
|
||||||
const server = http.createServer((req, res) => {
|
const server = http.createServer((req, res) => {
|
||||||
|
|||||||
@@ -3,8 +3,8 @@
|
|||||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState } from 'react';
|
import { useState, useEffect } from 'react';
|
||||||
import { Modal, Radio, RadioGroup, Typography } from '@douyinfe/semi-ui-19';
|
import { Modal, Radio, RadioGroup, Typography, Checkbox } from '@douyinfe/semi-ui-19';
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
@@ -15,11 +15,24 @@ const ListingDeletionModal = ({
|
|||||||
title = 'Delete Listings',
|
title = 'Delete Listings',
|
||||||
showOptions = true,
|
showOptions = true,
|
||||||
message = 'How would you like to delete the selected listing(s)?',
|
message = 'How would you like to delete the selected listing(s)?',
|
||||||
|
defaultDeleteType = 'soft',
|
||||||
}) => {
|
}) => {
|
||||||
const [deleteType, setDeleteType] = useState('soft');
|
const [deleteType, setDeleteType] = useState('soft');
|
||||||
|
const [remember, setRemember] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (visible) {
|
||||||
|
setDeleteType(defaultDeleteType);
|
||||||
|
setRemember(false);
|
||||||
|
}
|
||||||
|
}, [visible, defaultDeleteType]);
|
||||||
|
|
||||||
const handleOk = () => {
|
const handleOk = () => {
|
||||||
onConfirm(!showOptions || deleteType === 'hard');
|
if (showOptions) {
|
||||||
|
onConfirm(deleteType === 'hard', remember);
|
||||||
|
} else {
|
||||||
|
onConfirm(true);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -36,32 +49,37 @@ const ListingDeletionModal = ({
|
|||||||
<Text>{message}</Text>
|
<Text>{message}</Text>
|
||||||
</div>
|
</div>
|
||||||
{showOptions && (
|
{showOptions && (
|
||||||
<RadioGroup value={deleteType} onChange={(e) => setDeleteType(e.target.value)} style={{ width: '100%' }}>
|
<>
|
||||||
<Radio value="soft" style={{ alignItems: 'flex-start', width: '100%' }}>
|
<RadioGroup value={deleteType} onChange={(e) => setDeleteType(e.target.value)} style={{ width: '100%' }}>
|
||||||
<div style={{ marginLeft: 8 }}>
|
<Radio value="soft" style={{ alignItems: 'flex-start', width: '100%' }}>
|
||||||
<Text strong>Mark as deleted (Soft Delete)</Text>
|
<div style={{ marginLeft: 8 }}>
|
||||||
<br />
|
<Text strong>Mark as deleted (Soft Delete)</Text>
|
||||||
<Text type="secondary">
|
|
||||||
Listings are kept in the database but marked as hidden. They will <b>not</b> re-appear during the next
|
|
||||||
scraping session.
|
|
||||||
</Text>
|
|
||||||
</div>
|
|
||||||
</Radio>
|
|
||||||
<Radio value="hard" style={{ marginTop: 16, alignItems: 'flex-start', width: '100%' }}>
|
|
||||||
<div style={{ marginLeft: 8 }}>
|
|
||||||
<Text strong>Remove from database (Hard Delete)</Text>
|
|
||||||
<br />
|
|
||||||
<Text type="secondary">
|
|
||||||
Listings are completely removed from the database.
|
|
||||||
<br />
|
<br />
|
||||||
<Text type="warning">
|
<Text type="secondary">
|
||||||
Consequence: They might re-appear when scraping the next time because Fredy won't know they were
|
Listings are kept in the database but marked as hidden. They will <b>not</b> re-appear during the next
|
||||||
previously found.
|
scraping session.
|
||||||
</Text>
|
</Text>
|
||||||
</Text>
|
</div>
|
||||||
</div>
|
</Radio>
|
||||||
</Radio>
|
<Radio value="hard" style={{ marginTop: 16, alignItems: 'flex-start', width: '100%' }}>
|
||||||
</RadioGroup>
|
<div style={{ marginLeft: 8 }}>
|
||||||
|
<Text strong>Remove from database (Hard Delete)</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary">
|
||||||
|
Listings are completely removed from the database.
|
||||||
|
<br />
|
||||||
|
<Text type="warning">
|
||||||
|
Consequence: They might re-appear when scraping the next time because Fredy won't know they were
|
||||||
|
previously found.
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Radio>
|
||||||
|
</RadioGroup>
|
||||||
|
<Checkbox checked={remember} onChange={(e) => setRemember(e.target.checked)} style={{ marginTop: 16 }}>
|
||||||
|
Remember my choice and skip this dialog next time
|
||||||
|
</Checkbox>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -60,6 +60,8 @@ const JobGrid = () => {
|
|||||||
|
|
||||||
const userSettings = useSelector((state) => state.userSettings.settings);
|
const userSettings = useSelector((state) => state.userSettings.settings);
|
||||||
const viewMode = userSettings?.jobs_view_mode ?? 'grid';
|
const viewMode = userSettings?.jobs_view_mode ?? 'grid';
|
||||||
|
const listingDeletionPref = userSettings?.listing_deletion_preference;
|
||||||
|
const defaultDeleteType = listingDeletionPref?.hardDelete ? 'hard' : 'soft';
|
||||||
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useState(1);
|
||||||
const pageSize = 12;
|
const pageSize = 12;
|
||||||
@@ -142,13 +144,21 @@ const JobGrid = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const onListingRemoval = (jobId) => {
|
const onListingRemoval = (jobId) => {
|
||||||
setPendingDeletion({ type: 'listings', jobId });
|
const deletion = { type: 'listings', jobId };
|
||||||
|
if (listingDeletionPref?.skipPrompt) {
|
||||||
|
confirmDeletion(listingDeletionPref.hardDelete, false, deletion);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setPendingDeletion(deletion);
|
||||||
setDeleteModalVisible(true);
|
setDeleteModalVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDeletion = async (hardDelete) => {
|
const confirmDeletion = async (hardDelete, remember, deletion = pendingDeletion) => {
|
||||||
const { type, jobId } = pendingDeletion;
|
const { type, jobId } = deletion;
|
||||||
try {
|
try {
|
||||||
|
if (remember && type === 'listings') {
|
||||||
|
await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete });
|
||||||
|
}
|
||||||
if (type === 'job') {
|
if (type === 'job') {
|
||||||
await xhrDelete('/api/jobs', { jobId });
|
await xhrDelete('/api/jobs', { jobId });
|
||||||
Toast.success('Job and listings successfully removed');
|
Toast.success('Job and listings successfully removed');
|
||||||
@@ -425,6 +435,7 @@ const JobGrid = () => {
|
|||||||
visible={deleteModalVisible}
|
visible={deleteModalVisible}
|
||||||
title={pendingDeletion?.type === 'job' ? 'Delete Job' : 'Delete Listings'}
|
title={pendingDeletion?.type === 'job' ? 'Delete Job' : 'Delete Listings'}
|
||||||
showOptions={pendingDeletion?.type !== 'job'}
|
showOptions={pendingDeletion?.type !== 'job'}
|
||||||
|
defaultDeleteType={defaultDeleteType}
|
||||||
message={
|
message={
|
||||||
pendingDeletion?.type === 'job'
|
pendingDeletion?.type === 'job'
|
||||||
? 'Are you sure you want to delete this job? All associated listings will be removed from the database.'
|
? 'Are you sure you want to delete this job? All associated listings will be removed from the database.'
|
||||||
|
|||||||
@@ -33,6 +33,8 @@ const ListingsOverview = () => {
|
|||||||
const sp = useSearchParams();
|
const sp = useSearchParams();
|
||||||
|
|
||||||
const viewMode = userSettings?.listings_view_mode ?? 'grid';
|
const viewMode = userSettings?.listings_view_mode ?? 'grid';
|
||||||
|
const listingDeletionPref = userSettings?.listing_deletion_preference;
|
||||||
|
const defaultDeleteType = listingDeletionPref?.hardDelete ? 'hard' : 'soft';
|
||||||
|
|
||||||
const [page, setPage] = useSearchParamState(sp, 'page', 1, parseNumber);
|
const [page, setPage] = useSearchParamState(sp, 'page', 1, parseNumber);
|
||||||
const pageSize = 40;
|
const pageSize = 40;
|
||||||
@@ -91,15 +93,22 @@ const ListingsOverview = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = (id) => {
|
const handleDelete = (id) => {
|
||||||
|
if (listingDeletionPref?.skipPrompt) {
|
||||||
|
confirmDeletion(listingDeletionPref.hardDelete, false, id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
setListingToDelete(id);
|
setListingToDelete(id);
|
||||||
setDeleteModalVisible(true);
|
setDeleteModalVisible(true);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleNavigate = (id) => navigate(`/listings/listing/${id}`);
|
const handleNavigate = (id) => navigate(`/listings/listing/${id}`);
|
||||||
|
|
||||||
const confirmDeletion = async (hardDelete) => {
|
const confirmDeletion = async (hardDelete, remember, id = listingToDelete) => {
|
||||||
try {
|
try {
|
||||||
await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete });
|
if (remember) {
|
||||||
|
await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete });
|
||||||
|
}
|
||||||
|
await xhrDelete('/api/listings/', { ids: [id], hardDelete });
|
||||||
Toast.success('Listing successfully removed');
|
Toast.success('Listing successfully removed');
|
||||||
loadData();
|
loadData();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -251,6 +260,7 @@ const ListingsOverview = () => {
|
|||||||
|
|
||||||
<ListingDeletionModal
|
<ListingDeletionModal
|
||||||
visible={deleteModalVisible}
|
visible={deleteModalVisible}
|
||||||
|
defaultDeleteType={defaultDeleteType}
|
||||||
onConfirm={confirmDeletion}
|
onConfirm={confirmDeletion}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setDeleteModalVisible(false);
|
setDeleteModalVisible(false);
|
||||||
|
|||||||
@@ -349,6 +349,20 @@ export const useFredyState = create(
|
|||||||
throw Exception;
|
throw Exception;
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
async setListingDeletionPreference(listing_deletion_preference) {
|
||||||
|
try {
|
||||||
|
await xhrPost('/api/user/settings/listing-deletion-preference', { listing_deletion_preference });
|
||||||
|
set((state) => ({
|
||||||
|
userSettings: {
|
||||||
|
...state.userSettings,
|
||||||
|
settings: { ...state.userSettings.settings, listing_deletion_preference },
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
} catch (Exception) {
|
||||||
|
console.error('Error while trying to update listing deletion preference. Error:', Exception);
|
||||||
|
throw Exception;
|
||||||
|
}
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ import {
|
|||||||
AutoComplete,
|
AutoComplete,
|
||||||
Select,
|
Select,
|
||||||
Banner,
|
Banner,
|
||||||
|
Radio,
|
||||||
|
RadioGroup,
|
||||||
|
Typography,
|
||||||
} from '@douyinfe/semi-ui-19';
|
} from '@douyinfe/semi-ui-19';
|
||||||
import { InputNumber } from '@douyinfe/semi-ui-19';
|
import { InputNumber } from '@douyinfe/semi-ui-19';
|
||||||
import { xhrPost, xhrGet } from '../../services/xhr';
|
import { xhrPost, xhrGet } from '../../services/xhr';
|
||||||
@@ -33,6 +36,8 @@ import { debounce } from '../../utils';
|
|||||||
import Headline from '../../components/headline/Headline.jsx';
|
import Headline from '../../components/headline/Headline.jsx';
|
||||||
import './GeneralSettings.less';
|
import './GeneralSettings.less';
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
function formatFromTimestamp(ts) {
|
function formatFromTimestamp(ts) {
|
||||||
const date = new Date(ts);
|
const date = new Date(ts);
|
||||||
return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`;
|
return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`;
|
||||||
@@ -74,9 +79,12 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
// User settings state
|
// User settings state
|
||||||
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
||||||
const providerDetails = useSelector((state) => state.userSettings.settings.provider_details);
|
const providerDetails = useSelector((state) => state.userSettings.settings.provider_details);
|
||||||
|
const listingDeletionPreference = useSelector((state) => state.userSettings.settings.listing_deletion_preference);
|
||||||
const allProviders = useSelector((state) => state.provider);
|
const allProviders = useSelector((state) => state.provider);
|
||||||
const [address, setAddress] = useState(homeAddress?.address || '');
|
const [address, setAddress] = useState(homeAddress?.address || '');
|
||||||
const [coords, setCoords] = useState(homeAddress?.coords || null);
|
const [coords, setCoords] = useState(homeAddress?.coords || null);
|
||||||
|
const [listingDeleteHard, setListingDeleteHard] = useState(false);
|
||||||
|
const [listingDeleteSkipPrompt, setListingDeleteSkipPrompt] = useState(false);
|
||||||
const saving = useIsLoading(actions.userSettings.setHomeAddress);
|
const saving = useIsLoading(actions.userSettings.setHomeAddress);
|
||||||
const [dataSource, setDataSource] = useState([]);
|
const [dataSource, setDataSource] = useState([]);
|
||||||
|
|
||||||
@@ -110,6 +118,11 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
setCoords(homeAddress?.coords || null);
|
setCoords(homeAddress?.coords || null);
|
||||||
}, [homeAddress]);
|
}, [homeAddress]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setListingDeleteHard(listingDeletionPreference?.hardDelete ?? false);
|
||||||
|
setListingDeleteSkipPrompt(listingDeletionPreference?.skipPrompt ?? false);
|
||||||
|
}, [listingDeletionPreference]);
|
||||||
|
|
||||||
const nullOrEmpty = (val) => val == null || val.length === 0;
|
const nullOrEmpty = (val) => val == null || val.length === 0;
|
||||||
|
|
||||||
const handleStore = async () => {
|
const handleStore = async () => {
|
||||||
@@ -218,6 +231,10 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
try {
|
try {
|
||||||
const responseJson = await actions.userSettings.setHomeAddress(address);
|
const responseJson = await actions.userSettings.setHomeAddress(address);
|
||||||
setCoords(responseJson.coords);
|
setCoords(responseJson.coords);
|
||||||
|
await actions.userSettings.setListingDeletionPreference({
|
||||||
|
skipPrompt: listingDeleteSkipPrompt,
|
||||||
|
hardDelete: listingDeleteHard,
|
||||||
|
});
|
||||||
await actions.userSettings.getUserSettings();
|
await actions.userSettings.getUserSettings();
|
||||||
Toast.success('Settings saved. Distance calculations are running in the background.');
|
Toast.success('Settings saved. Distance calculations are running in the background.');
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -459,6 +476,48 @@ const GeneralSettings = function GeneralSettings() {
|
|||||||
/>
|
/>
|
||||||
</SegmentPart>
|
</SegmentPart>
|
||||||
|
|
||||||
|
<SegmentPart
|
||||||
|
name="Listing deletion"
|
||||||
|
helpText="Choose the default deletion mode. Soft delete hides them without re-scraping; hard delete removes them from the database."
|
||||||
|
>
|
||||||
|
<RadioGroup
|
||||||
|
value={listingDeleteHard ? 'hard' : 'soft'}
|
||||||
|
onChange={(e) => setListingDeleteHard(e.target.value === 'hard')}
|
||||||
|
>
|
||||||
|
<Radio value="soft">
|
||||||
|
<div>
|
||||||
|
<Text strong>Mark as deleted (Soft Delete)</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary">
|
||||||
|
Listings are kept in the database but marked as hidden. They will <b>not</b> re-appear during
|
||||||
|
the next scraping session.
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Radio>
|
||||||
|
<Radio value="hard">
|
||||||
|
<div>
|
||||||
|
<Text strong>Remove from database (Hard Delete)</Text>
|
||||||
|
<br />
|
||||||
|
<Text type="secondary">
|
||||||
|
Listings are completely removed from the database.
|
||||||
|
<br />
|
||||||
|
<Text type="warning">
|
||||||
|
Consequence: They might re-appear when scraping the next time because Fredy won't know they
|
||||||
|
were previously found.
|
||||||
|
</Text>
|
||||||
|
</Text>
|
||||||
|
</div>
|
||||||
|
</Radio>
|
||||||
|
</RadioGroup>
|
||||||
|
<Checkbox
|
||||||
|
checked={listingDeleteSkipPrompt}
|
||||||
|
onChange={(e) => setListingDeleteSkipPrompt(e.target.checked)}
|
||||||
|
style={{ marginTop: 12 }}
|
||||||
|
>
|
||||||
|
Skip confirmation dialog
|
||||||
|
</Checkbox>
|
||||||
|
</SegmentPart>
|
||||||
|
|
||||||
<div className="generalSettings__save-row">
|
<div className="generalSettings__save-row">
|
||||||
<Button
|
<Button
|
||||||
icon={<IconSave />}
|
icon={<IconSave />}
|
||||||
|
|||||||
@@ -57,7 +57,10 @@ export default function ListingDetail() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
const listing = useSelector((state) => state.listingsData.currentListing);
|
const listing = useSelector((state) => state.listingsData.currentListing);
|
||||||
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
const userSettings = useSelector((state) => state.userSettings.settings);
|
||||||
|
const homeAddress = userSettings?.home_address;
|
||||||
|
const listingDeletionPref = userSettings?.listing_deletion_preference;
|
||||||
|
const defaultDeleteType = listingDeletionPref?.hardDelete ? 'hard' : 'soft';
|
||||||
const mapContainer = useRef(null);
|
const mapContainer = useRef(null);
|
||||||
const map = useRef(null);
|
const map = useRef(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -242,8 +245,11 @@ export default function ListingDetail() {
|
|||||||
};
|
};
|
||||||
}, [listing, loading, homeAddress]);
|
}, [listing, loading, homeAddress]);
|
||||||
|
|
||||||
const confirmDeletion = async (hardDelete) => {
|
const confirmDeletion = async (hardDelete, remember) => {
|
||||||
try {
|
try {
|
||||||
|
if (remember) {
|
||||||
|
await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete });
|
||||||
|
}
|
||||||
await xhrDelete('/api/listings/', { ids: [listing.id], hardDelete });
|
await xhrDelete('/api/listings/', { ids: [listing.id], hardDelete });
|
||||||
Toast.success('Listing successfully removed');
|
Toast.success('Listing successfully removed');
|
||||||
navigate('/listings');
|
navigate('/listings');
|
||||||
@@ -347,7 +353,13 @@ export default function ListingDetail() {
|
|||||||
</a>
|
</a>
|
||||||
<Button
|
<Button
|
||||||
icon={<IconDelete />}
|
icon={<IconDelete />}
|
||||||
onClick={() => setDeleteModalVisible(true)}
|
onClick={() => {
|
||||||
|
if (listingDeletionPref?.skipPrompt) {
|
||||||
|
confirmDeletion(listingDeletionPref.hardDelete);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setDeleteModalVisible(true);
|
||||||
|
}}
|
||||||
theme="light"
|
theme="light"
|
||||||
type="danger"
|
type="danger"
|
||||||
>
|
>
|
||||||
@@ -423,6 +435,7 @@ export default function ListingDetail() {
|
|||||||
|
|
||||||
<ListingDeletionModal
|
<ListingDeletionModal
|
||||||
visible={deleteModalVisible}
|
visible={deleteModalVisible}
|
||||||
|
defaultDeleteType={defaultDeleteType}
|
||||||
onConfirm={confirmDeletion}
|
onConfirm={confirmDeletion}
|
||||||
onCancel={() => setDeleteModalVisible(false)}
|
onCancel={() => setDeleteModalVisible(false)}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -37,7 +37,10 @@ export default function MapView() {
|
|||||||
const sp = useSearchParams();
|
const sp = useSearchParams();
|
||||||
const [searchParams, setSearchParams] = sp;
|
const [searchParams, setSearchParams] = sp;
|
||||||
const listings = useSelector((state) => state.listingsData.mapListings);
|
const listings = useSelector((state) => state.listingsData.mapListings);
|
||||||
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
const userSettings = useSelector((state) => state.userSettings.settings);
|
||||||
|
const homeAddress = userSettings?.home_address;
|
||||||
|
const listingDeletionPref = userSettings?.listing_deletion_preference;
|
||||||
|
const defaultDeleteType = listingDeletionPref?.hardDelete ? 'hard' : 'soft';
|
||||||
|
|
||||||
const jobs = useSelector((state) => state.jobsData.jobs);
|
const jobs = useSelector((state) => state.jobsData.jobs);
|
||||||
const [jobId, setJobId] = useSearchParamState(sp, 'job', null, parseString);
|
const [jobId, setJobId] = useSearchParamState(sp, 'job', null, parseString);
|
||||||
@@ -52,10 +55,14 @@ export default function MapView() {
|
|||||||
|
|
||||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||||
const [listingToDelete, setListingToDelete] = useState(null);
|
const [listingToDelete, setListingToDelete] = useState(null);
|
||||||
|
const deleteListingRef = useRef(null);
|
||||||
|
|
||||||
const confirmListingDeletion = async (hardDelete) => {
|
const confirmListingDeletion = async (hardDelete, remember, id = listingToDelete) => {
|
||||||
try {
|
try {
|
||||||
await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete });
|
if (remember) {
|
||||||
|
await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete });
|
||||||
|
}
|
||||||
|
await xhrDelete('/api/listings/', { ids: [id], hardDelete });
|
||||||
Toast.success('Listing successfully removed');
|
Toast.success('Listing successfully removed');
|
||||||
fetchListings();
|
fetchListings();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -66,6 +73,15 @@ export default function MapView() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
deleteListingRef.current = (id) => {
|
||||||
|
if (listingDeletionPref?.skipPrompt) {
|
||||||
|
confirmListingDeletion(listingDeletionPref.hardDelete, false, id);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setListingToDelete(id);
|
||||||
|
setDeleteModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// Only reset to full range when no URL override is set
|
// Only reset to full range when no URL override is set
|
||||||
if (urlPriceMax === null) {
|
if (urlPriceMax === null) {
|
||||||
@@ -88,10 +104,7 @@ export default function MapView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
window.deleteListing = (id) => {
|
window.deleteListing = (id) => deleteListingRef.current(id);
|
||||||
setListingToDelete(id);
|
|
||||||
setDeleteModalVisible(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.viewDetails = (id) => {
|
window.viewDetails = (id) => {
|
||||||
navigate(`/listings/listing/${id}`);
|
navigate(`/listings/listing/${id}`);
|
||||||
@@ -472,6 +485,7 @@ export default function MapView() {
|
|||||||
|
|
||||||
<ListingDeletionModal
|
<ListingDeletionModal
|
||||||
visible={deleteModalVisible}
|
visible={deleteModalVisible}
|
||||||
|
defaultDeleteType={defaultDeleteType}
|
||||||
onConfirm={confirmListingDeletion}
|
onConfirm={confirmListingDeletion}
|
||||||
onCancel={() => {
|
onCancel={() => {
|
||||||
setDeleteModalVisible(false);
|
setDeleteModalVisible(false);
|
||||||
|
|||||||
Reference in New Issue
Block a user