diff --git a/lib/api/routes/userSettingsRoute.js b/lib/api/routes/userSettingsRoute.js index f5788aa..3e52073 100644 --- a/lib/api/routes/userSettingsRoute.js +++ b/lib/api/routes/userSettingsRoute.js @@ -151,4 +151,28 @@ export default async function userSettingsPlugin(fastify) { return reply.code(500).send({ error: error.message }); } }); + + fastify.post('/listing-deletion-preference', async (request, reply) => { + const userId = request.session.currentUser; + const { listing_deletion_preference } = request.body; + + const globalSettings = await getSettings(); + if (globalSettings.demoMode && !isAdmin(request)) { + return reply.code(403).send({ error: 'In demo mode, it is not allowed to change settings.' }); + } + + if (listing_deletion_preference == null) { + return reply.code(400).send({ error: 'listing_deletion_preference is required.' }); + } + + const { skipPrompt, hardDelete } = listing_deletion_preference; + + try { + upsertSettings({ listing_deletion_preference: { skipPrompt, hardDelete } }, userId); + return { success: true }; + } catch (error) { + logger.error('Error updating listing deletion preference', error); + return reply.code(500).send({ error: error.message }); + } + }); } diff --git a/tools/devMock.js b/tools/devMock.js index a1f50e0..f5e9141 100644 --- a/tools/devMock.js +++ b/tools/devMock.js @@ -155,6 +155,7 @@ const routes = { 'GET /api/dashboard': dashboard, 'GET /api/demo': { demoMode: false }, 'POST /api/user/settings/news-hash': {}, + 'POST /api/user/settings/listing-deletion-preference': {}, }; const server = http.createServer((req, res) => { diff --git a/ui/src/components/ListingDeletionModal.jsx b/ui/src/components/ListingDeletionModal.jsx index 224da03..2b878f6 100644 --- a/ui/src/components/ListingDeletionModal.jsx +++ b/ui/src/components/ListingDeletionModal.jsx @@ -3,8 +3,8 @@ * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause */ -import { useState } from 'react'; -import { Modal, Radio, RadioGroup, Typography } from '@douyinfe/semi-ui-19'; +import { useState, useEffect } from 'react'; +import { Modal, Radio, RadioGroup, Typography, Checkbox } from '@douyinfe/semi-ui-19'; const { Text } = Typography; @@ -15,11 +15,24 @@ const ListingDeletionModal = ({ title = 'Delete Listings', showOptions = true, message = 'How would you like to delete the selected listing(s)?', + defaultDeleteType = 'soft', }) => { const [deleteType, setDeleteType] = useState('soft'); + const [remember, setRemember] = useState(false); + + useEffect(() => { + if (visible) { + setDeleteType(defaultDeleteType); + setRemember(false); + } + }, [visible, defaultDeleteType]); const handleOk = () => { - onConfirm(!showOptions || deleteType === 'hard'); + if (showOptions) { + onConfirm(deleteType === 'hard', remember); + } else { + onConfirm(true); + } }; return ( @@ -36,32 +49,37 @@ const ListingDeletionModal = ({ {message} {showOptions && ( - setDeleteType(e.target.value)} style={{ width: '100%' }}> - -
- Mark as deleted (Soft Delete) -
- - Listings are kept in the database but marked as hidden. They will not re-appear during the next - scraping session. - -
-
- -
- Remove from database (Hard Delete) -
- - Listings are completely removed from the database. + <> + setDeleteType(e.target.value)} style={{ width: '100%' }}> + +
+ Mark as deleted (Soft Delete)
- - Consequence: They might re-appear when scraping the next time because Fredy won't know they were - previously found. + + Listings are kept in the database but marked as hidden. They will not re-appear during the next + scraping session. - -
-
-
+
+
+ +
+ Remove from database (Hard Delete) +
+ + Listings are completely removed from the database. +
+ + Consequence: They might re-appear when scraping the next time because Fredy won't know they were + previously found. + +
+
+
+
+ setRemember(e.target.checked)} style={{ marginTop: 16 }}> + Remember my choice and skip this dialog next time + + )} ); diff --git a/ui/src/components/grid/jobs/JobGrid.jsx b/ui/src/components/grid/jobs/JobGrid.jsx index 0aa77c1..cb4035d 100644 --- a/ui/src/components/grid/jobs/JobGrid.jsx +++ b/ui/src/components/grid/jobs/JobGrid.jsx @@ -60,6 +60,8 @@ const JobGrid = () => { const userSettings = useSelector((state) => state.userSettings.settings); const viewMode = userSettings?.jobs_view_mode ?? 'grid'; + const listingDeletionPref = userSettings?.listing_deletion_preference; + const defaultDeleteType = listingDeletionPref?.hardDelete ? 'hard' : 'soft'; const [page, setPage] = useState(1); const pageSize = 12; @@ -142,13 +144,21 @@ const JobGrid = () => { }; const onListingRemoval = (jobId) => { - setPendingDeletion({ type: 'listings', jobId }); + const deletion = { type: 'listings', jobId }; + if (listingDeletionPref?.skipPrompt) { + confirmDeletion(listingDeletionPref.hardDelete, false, deletion); + return; + } + setPendingDeletion(deletion); setDeleteModalVisible(true); }; - const confirmDeletion = async (hardDelete) => { - const { type, jobId } = pendingDeletion; + const confirmDeletion = async (hardDelete, remember, deletion = pendingDeletion) => { + const { type, jobId } = deletion; try { + if (remember && type === 'listings') { + await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete }); + } if (type === 'job') { await xhrDelete('/api/jobs', { jobId }); Toast.success('Job and listings successfully removed'); @@ -425,6 +435,7 @@ const JobGrid = () => { visible={deleteModalVisible} title={pendingDeletion?.type === 'job' ? 'Delete Job' : 'Delete Listings'} showOptions={pendingDeletion?.type !== 'job'} + defaultDeleteType={defaultDeleteType} message={ pendingDeletion?.type === 'job' ? 'Are you sure you want to delete this job? All associated listings will be removed from the database.' diff --git a/ui/src/components/listings/ListingsOverview.jsx b/ui/src/components/listings/ListingsOverview.jsx index a2a8b52..d227a37 100644 --- a/ui/src/components/listings/ListingsOverview.jsx +++ b/ui/src/components/listings/ListingsOverview.jsx @@ -33,6 +33,8 @@ const ListingsOverview = () => { const sp = useSearchParams(); const viewMode = userSettings?.listings_view_mode ?? 'grid'; + const listingDeletionPref = userSettings?.listing_deletion_preference; + const defaultDeleteType = listingDeletionPref?.hardDelete ? 'hard' : 'soft'; const [page, setPage] = useSearchParamState(sp, 'page', 1, parseNumber); const pageSize = 40; @@ -91,15 +93,22 @@ const ListingsOverview = () => { }; const handleDelete = (id) => { + if (listingDeletionPref?.skipPrompt) { + confirmDeletion(listingDeletionPref.hardDelete, false, id); + return; + } setListingToDelete(id); setDeleteModalVisible(true); }; const handleNavigate = (id) => navigate(`/listings/listing/${id}`); - const confirmDeletion = async (hardDelete) => { + const confirmDeletion = async (hardDelete, remember, id = listingToDelete) => { try { - await xhrDelete('/api/listings/', { ids: [listingToDelete], hardDelete }); + if (remember) { + await actions.userSettings.setListingDeletionPreference({ skipPrompt: true, hardDelete }); + } + await xhrDelete('/api/listings/', { ids: [id], hardDelete }); Toast.success('Listing successfully removed'); loadData(); } catch (error) { @@ -251,6 +260,7 @@ const ListingsOverview = () => { { setDeleteModalVisible(false); diff --git a/ui/src/services/state/store.js b/ui/src/services/state/store.js index a13ebff..e0d276a 100644 --- a/ui/src/services/state/store.js +++ b/ui/src/services/state/store.js @@ -349,6 +349,20 @@ export const useFredyState = create( throw Exception; } }, + async setListingDeletionPreference(listing_deletion_preference) { + try { + await xhrPost('/api/user/settings/listing-deletion-preference', { listing_deletion_preference }); + set((state) => ({ + userSettings: { + ...state.userSettings, + settings: { ...state.userSettings.settings, listing_deletion_preference }, + }, + })); + } catch (Exception) { + console.error('Error while trying to update listing deletion preference. Error:', Exception); + throw Exception; + } + }, }, }; diff --git a/ui/src/views/generalSettings/GeneralSettings.jsx b/ui/src/views/generalSettings/GeneralSettings.jsx index b53fd21..9c7e451 100644 --- a/ui/src/views/generalSettings/GeneralSettings.jsx +++ b/ui/src/views/generalSettings/GeneralSettings.jsx @@ -18,6 +18,9 @@ import { AutoComplete, Select, Banner, + Radio, + RadioGroup, + Typography, } from '@douyinfe/semi-ui-19'; import { InputNumber } from '@douyinfe/semi-ui-19'; import { xhrPost, xhrGet } from '../../services/xhr'; @@ -33,6 +36,8 @@ import { debounce } from '../../utils'; import Headline from '../../components/headline/Headline.jsx'; import './GeneralSettings.less'; +const { Text } = Typography; + function formatFromTimestamp(ts) { const date = new Date(ts); return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`; @@ -74,9 +79,12 @@ const GeneralSettings = function GeneralSettings() { // User settings state const homeAddress = useSelector((state) => state.userSettings.settings.home_address); const providerDetails = useSelector((state) => state.userSettings.settings.provider_details); + const listingDeletionPreference = useSelector((state) => state.userSettings.settings.listing_deletion_preference); const allProviders = useSelector((state) => state.provider); const [address, setAddress] = useState(homeAddress?.address || ''); const [coords, setCoords] = useState(homeAddress?.coords || null); + const [listingDeleteHard, setListingDeleteHard] = useState(false); + const [listingDeleteSkipPrompt, setListingDeleteSkipPrompt] = useState(false); const saving = useIsLoading(actions.userSettings.setHomeAddress); const [dataSource, setDataSource] = useState([]); @@ -110,6 +118,11 @@ const GeneralSettings = function GeneralSettings() { setCoords(homeAddress?.coords || null); }, [homeAddress]); + useEffect(() => { + setListingDeleteHard(listingDeletionPreference?.hardDelete ?? false); + setListingDeleteSkipPrompt(listingDeletionPreference?.skipPrompt ?? false); + }, [listingDeletionPreference]); + const nullOrEmpty = (val) => val == null || val.length === 0; const handleStore = async () => { @@ -218,6 +231,10 @@ const GeneralSettings = function GeneralSettings() { try { const responseJson = await actions.userSettings.setHomeAddress(address); setCoords(responseJson.coords); + await actions.userSettings.setListingDeletionPreference({ + skipPrompt: listingDeleteSkipPrompt, + hardDelete: listingDeleteHard, + }); await actions.userSettings.getUserSettings(); Toast.success('Settings saved. Distance calculations are running in the background.'); } catch (error) { @@ -459,6 +476,48 @@ const GeneralSettings = function GeneralSettings() { /> + + setListingDeleteHard(e.target.value === 'hard')} + > + +
+ Mark as deleted (Soft Delete) +
+ + Listings are kept in the database but marked as hidden. They will not re-appear during + the next scraping session. + +
+
+ +
+ Remove from database (Hard Delete) +
+ + Listings are completely removed from the database. +
+ + Consequence: They might re-appear when scraping the next time because Fredy won't know they + were previously found. + +
+
+
+
+ setListingDeleteSkipPrompt(e.target.checked)} + style={{ marginTop: 12 }} + > + Skip confirmation dialog + +
+