2025-12-11 10:40:55 +01:00
/ *
2026-01-12 15:00:36 +01:00
* Copyright ( c ) 2026 by Christian Kellner .
2025-12-11 10:40:55 +01:00
* Licensed under Apache - 2.0 with Commons Clause and Attribution / Naming Clause
* /
2026-03-23 13:22:34 +01:00
import React , { useEffect , useState , useMemo } from 'react' ;
2021-05-30 09:37:45 +02:00
2026-03-23 13:22:34 +01:00
import { useActions , useSelector , useIsLoading } from '../../services/state/store' ;
2021-05-30 09:37:45 +02:00
2026-03-23 13:22:34 +01:00
import {
Tabs ,
TabPane ,
TimePicker ,
Button ,
Checkbox ,
Input ,
Modal ,
AutoComplete ,
2026-04-07 19:53:40 +02:00
Select ,
2026-03-23 13:22:34 +01:00
Banner ,
} from '@douyinfe/semi-ui-19' ;
2026-01-22 16:09:36 +01:00
import { InputNumber } from '@douyinfe/semi-ui-19' ;
2026-03-23 13:22:34 +01:00
import { xhrPost , xhrGet } from '../../services/xhr' ;
import { Toast } from '@douyinfe/semi-ui-19' ;
2025-07-23 08:47:26 +02:00
import { SegmentPart } from '../../components/segment/SegmentPart' ;
2025-12-17 15:48:56 +01:00
import {
downloadBackup as downloadBackupZip ,
precheckRestore as clientPrecheckRestore ,
restore as clientRestore ,
} from '../../services/backupRestoreClient' ;
2026-03-23 13:22:34 +01:00
import { IconSave , IconRefresh , IconSignal , IconHome , IconFolder } from '@douyinfe/semi-icons' ;
2026-04-07 19:53:40 +02:00
import { debounce } from '../../utils' ;
2021-05-30 09:37:45 +02:00
import './GeneralSettings.less' ;
2023-03-20 08:52:13 +01:00
function formatFromTimestamp ( ts ) {
2025-07-23 08:47:26 +02:00
const date = new Date ( ts ) ;
return ` ${ date . getHours ( ) } : ${ date . getMinutes ( ) > 9 ? date . getMinutes ( ) : '0' + date . getMinutes ( ) } ` ;
2023-03-20 08:52:13 +01:00
}
function formatFromTBackend ( time ) {
2025-07-23 08:47:26 +02:00
if ( time == null || time . length === 0 ) {
return null ;
}
const date = new Date ( ) ;
const split = time . split ( ':' ) ;
date . setHours ( split [ 0 ] ) ;
date . setMinutes ( split [ 1 ] ) ;
return date . getTime ( ) ;
2023-03-20 08:52:13 +01:00
}
const GeneralSettings = function GeneralSettings ( ) {
2025-09-18 20:09:11 +02:00
const actions = useActions ( ) ;
2025-07-23 08:47:26 +02:00
const [ loading , setLoading ] = React . useState ( true ) ;
const settings = useSelector ( ( state ) => state . generalSettings . settings ) ;
const [ interval , setInterval ] = React . useState ( '' ) ;
const [ port , setPort ] = React . useState ( '' ) ;
const [ workingHourFrom , setWorkingHourFrom ] = React . useState ( null ) ;
const [ workingHourTo , setWorkingHourTo ] = React . useState ( null ) ;
const [ demoMode , setDemoMode ] = React . useState ( null ) ;
const [ analyticsEnabled , setAnalyticsEnabled ] = React . useState ( null ) ;
2025-09-18 15:38:23 +02:00
const [ sqlitePath , setSqlitePath ] = React . useState ( null ) ;
2025-12-17 15:48:56 +01:00
const fileInputRef = React . useRef ( null ) ;
const [ restoreModalVisible , setRestoreModalVisible ] = React . useState ( false ) ;
const [ precheckInfo , setPrecheckInfo ] = React . useState ( null ) ;
const [ restoreBusy , setRestoreBusy ] = React . useState ( false ) ;
const [ selectedRestoreFile , setSelectedRestoreFile ] = React . useState ( null ) ;
2025-07-23 08:47:26 +02:00
2026-03-23 13:22:34 +01:00
// User settings state
const homeAddress = useSelector ( ( state ) => state . userSettings . settings . home _address ) ;
2026-04-07 19:53:40 +02:00
const providerDetails = useSelector ( ( state ) => state . userSettings . settings . provider _details ) ;
const allProviders = useSelector ( ( state ) => state . provider ) ;
2026-03-23 13:22:34 +01:00
const [ address , setAddress ] = useState ( homeAddress ? . address || '' ) ;
const [ coords , setCoords ] = useState ( homeAddress ? . coords || null ) ;
const saving = useIsLoading ( actions . userSettings . setHomeAddress ) ;
const [ dataSource , setDataSource ] = useState ( [ ] ) ;
2025-07-23 08:47:26 +02:00
React . useEffect ( ( ) => {
async function init ( ) {
2025-09-18 20:09:11 +02:00
await actions . generalSettings . getGeneralSettings ( ) ;
2025-07-23 08:47:26 +02:00
setLoading ( false ) ;
}
2024-11-20 22:22:16 +01:00
2025-07-23 08:47:26 +02:00
init ( ) ;
} , [ ] ) ;
React . useEffect ( ( ) => {
async function init ( ) {
setInterval ( settings ? . interval ) ;
setPort ( settings ? . port ) ;
setWorkingHourFrom ( settings ? . workingHours ? . from ) ;
setWorkingHourTo ( settings ? . workingHours ? . to ) ;
setAnalyticsEnabled ( settings ? . analyticsEnabled || false ) ;
setDemoMode ( settings ? . demoMode || false ) ;
2025-09-18 15:38:23 +02:00
setSqlitePath ( settings ? . sqlitepath ) ;
2025-07-23 08:47:26 +02:00
}
2024-11-20 22:22:16 +01:00
2025-07-23 08:47:26 +02:00
init ( ) ;
} , [ settings ] ) ;
2024-11-20 22:22:16 +01:00
2026-03-23 13:22:34 +01:00
useEffect ( ( ) => {
setAddress ( homeAddress ? . address || '' ) ;
setCoords ( homeAddress ? . coords || null ) ;
} , [ homeAddress ] ) ;
2025-07-23 08:47:26 +02:00
const nullOrEmpty = ( val ) => val == null || val . length === 0 ;
2024-11-20 22:22:16 +01:00
2025-12-17 15:48:56 +01:00
const handleStore = async ( ) => {
2025-07-23 08:47:26 +02:00
if ( nullOrEmpty ( interval ) ) {
2025-08-01 09:51:42 +02:00
Toast . error ( 'Interval may not be empty.' ) ;
2025-07-23 08:47:26 +02:00
return ;
}
if ( nullOrEmpty ( port ) ) {
2025-08-01 09:51:42 +02:00
Toast . error ( 'Port may not be empty.' ) ;
2025-07-23 08:47:26 +02:00
return ;
}
if (
( ! nullOrEmpty ( workingHourFrom ) && nullOrEmpty ( workingHourTo ) ) ||
( nullOrEmpty ( workingHourFrom ) && ! nullOrEmpty ( workingHourTo ) )
) {
2025-08-01 09:51:42 +02:00
Toast . error ( 'Working hours to and from must be set if either to or from has been set before.' ) ;
2025-07-23 08:47:26 +02:00
return ;
}
2025-09-18 15:38:23 +02:00
if ( nullOrEmpty ( sqlitePath ) ) {
Toast . error ( 'SQLite db path cannot be empty.' ) ;
return ;
}
2025-07-23 08:47:26 +02:00
try {
await xhrPost ( '/api/admin/generalSettings' , {
interval ,
port ,
workingHours : {
from : workingHourFrom ,
to : workingHourTo ,
} ,
demoMode ,
analyticsEnabled ,
2025-09-18 15:38:23 +02:00
sqlitepath : sqlitePath ,
2025-07-23 08:47:26 +02:00
} ) ;
} catch ( exception ) {
console . error ( exception ) ;
if ( exception ? . json ? . message != null ) {
2025-08-01 09:51:42 +02:00
Toast . error ( exception . json . message ) ;
2025-07-23 08:47:26 +02:00
} else {
2025-08-01 09:51:42 +02:00
Toast . error ( 'Error while trying to store settings.' ) ;
2025-07-23 08:47:26 +02:00
}
return ;
}
2025-08-01 09:51:42 +02:00
Toast . success ( 'Settings stored successfully. We will reload your browser in 3 seconds.' ) ;
2025-07-23 08:47:26 +02:00
setTimeout ( ( ) => {
location . reload ( ) ;
} , 3000 ) ;
} ;
2025-12-17 15:48:56 +01:00
const handleDownloadBackup = React . useCallback ( async ( ) => {
try {
await downloadBackupZip ( ) ;
} catch ( e ) {
console . error ( e ) ;
Toast . error ( 'Unexpected error while downloading backup.' ) ;
}
} , [ ] ) ;
const precheckRestore = React . useCallback ( async ( file ) => {
try {
const data = await clientPrecheckRestore ( file ) ;
setPrecheckInfo ( data ) ;
setRestoreModalVisible ( true ) ;
} catch ( e ) {
console . error ( e ) ;
Toast . error ( 'Failed to analyze backup.' ) ;
}
} , [ ] ) ;
const performRestore = React . useCallback (
async ( force ) => {
try {
setRestoreBusy ( true ) ;
await clientRestore ( selectedRestoreFile , force ) ;
Toast . success ( 'Restore completed. Please restart the Fredy backend now!' ) ;
} catch ( e ) {
console . error ( e ) ;
Toast . error ( e ? . message || 'Unexpected error while restoring backup.' ) ;
} finally {
setRestoreBusy ( false ) ;
}
} ,
[ selectedRestoreFile ] ,
) ;
const handleSelectRestoreFile = React . useCallback (
async ( ev ) => {
const file = ev ? . target ? . files ? . [ 0 ] ;
if ( ! file ) return ;
setSelectedRestoreFile ( file ) ;
await precheckRestore ( file ) ;
ev . target . value = '' ;
} ,
[ precheckRestore ] ,
) ;
const handleOpenFilePicker = React . useCallback ( ( ) => {
if ( fileInputRef . current ) {
fileInputRef . current . click ( ) ;
}
} , [ ] ) ;
2026-03-23 13:22:34 +01:00
const handleSaveUserSettings = async ( ) => {
try {
const responseJson = await actions . userSettings . setHomeAddress ( address ) ;
setCoords ( responseJson . coords ) ;
await actions . userSettings . getUserSettings ( ) ;
Toast . success ( 'Settings saved. Distance calculations are running in the background.' ) ;
} catch ( error ) {
Toast . error ( error . json ? . error || 'Error while saving settings' ) ;
}
} ;
const debouncedSearch = useMemo (
( ) =>
debounce ( ( value ) => {
xhrGet ( ` /api/user/settings/autocomplete?q= ${ encodeURIComponent ( value ) } ` )
. then ( ( response ) => {
if ( response . status === 200 ) {
setDataSource ( response . json ) ;
}
} )
. catch ( ( ) => { } ) ;
} , 300 ) ,
[ ] ,
) ;
const searchAddress = ( value ) => {
if ( ! value ) {
setDataSource ( [ ] ) ;
return ;
}
debouncedSearch ( value ) ;
} ;
2025-07-23 08:47:26 +02:00
return (
2026-03-23 13:22:34 +01:00
< div className = "generalSettings" >
2025-07-23 08:47:26 +02:00
{ ! loading && (
2026-03-23 13:22:34 +01:00
< >
< Tabs type = "line" >
< TabPane
tab = {
< span >
< IconSignal size = "small" style = { { marginRight : 6 } } / >
System
< / span >
}
itemKey = "system"
2025-07-23 08:47:26 +02:00
>
2026-03-23 13:22:34 +01:00
< div className = "generalSettings__tab-content" >
< SegmentPart name = "Port" helpText = "The port on which Fredy is running." >
< InputNumber
min = { 0 }
max = { 99999 }
placeholder = "Port"
value = { port }
formatter = { ( value ) => ` ${ value } ` . replace ( /\D/g , '' ) }
onChange = { ( value ) => setPort ( value ) }
style = { { maxWidth : 160 } }
/ >
< / SegmentPart >
< SegmentPart
name = "SQLite Database Path"
helpText = "The directory where Fredy stores its SQLite database files."
>
< Banner
fullMode = { false }
type = "warning"
closeIcon = { null }
style = { { marginBottom : '12px' } }
description = "Changing this path may result in data loss. Restart Fredy immediately after saving."
/ >
< Input
type = "text"
placeholder = "Database folder path"
value = { sqlitePath }
onChange = { ( value ) => setSqlitePath ( value ) }
/ >
< / SegmentPart >
< SegmentPart
name = "Analytics"
helpText = "Anonymous usage data to help improve Fredy — provider names, adapter names, OS, Node version, and architecture."
>
< Checkbox checked = { analyticsEnabled } onChange = { ( e ) => setAnalyticsEnabled ( e . target . checked ) } >
Enable analytics
< / Checkbox >
< / SegmentPart >
< SegmentPart
name = "Demo Mode"
helpText = "In demo mode, Fredy will not search for real estates and all data resets to defaults at midnight."
>
< Checkbox checked = { demoMode } onChange = { ( e ) => setDemoMode ( e . target . checked ) } >
Enable demo mode
< / Checkbox >
< / SegmentPart >
< div className = "generalSettings__save-row" >
< Button type = "primary" theme = "solid" onClick = { handleStore } icon = { < IconSave / > } >
Save
< / Button >
< / div >
2025-12-17 15:48:56 +01:00
< / div >
2026-03-23 13:22:34 +01:00
< / TabPane >
< TabPane
tab = {
< span >
< IconRefresh size = "small" style = { { marginRight : 6 } } / >
Execution
< / span >
}
itemKey = "execution"
2025-09-18 15:38:23 +02:00
>
2026-03-23 13:22:34 +01:00
< div className = "generalSettings__tab-content" >
< SegmentPart
name = "Search Interval"
helpText = "Interval in minutes for running queries against configured services. Do not go below 5 minutes to avoid being detected as a bot."
>
< InputNumber
min = { 5 }
max = { 1440 }
placeholder = "Interval in minutes"
value = { interval }
formatter = { ( value ) => ` ${ value } ` . replace ( /\D/g , '' ) }
onChange = { ( value ) => setInterval ( value ) }
suffix = { 'minutes' }
style = { { maxWidth : 200 } }
/ >
< / SegmentPart >
< SegmentPart
name = "Working Hours"
helpText = "Fredy will only search for listings during these hours. Leave empty to search around the clock."
>
< div className = "generalSettings__timePickerContainer" >
< TimePicker
format = { 'HH:mm' }
insetLabel = "From"
value = { formatFromTBackend ( workingHourFrom ) }
placeholder = ""
onChange = { ( val ) => {
setWorkingHourFrom ( val == null ? null : formatFromTimestamp ( val ) ) ;
} }
/ >
< TimePicker
format = { 'HH:mm' }
insetLabel = "Until"
value = { formatFromTBackend ( workingHourTo ) }
placeholder = ""
onChange = { ( val ) => {
setWorkingHourTo ( val == null ? null : formatFromTimestamp ( val ) ) ;
} }
/ >
2025-09-18 15:38:23 +02:00
< / div >
2026-03-23 13:22:34 +01:00
< / SegmentPart >
< div className = "generalSettings__save-row" >
< Button type = "primary" theme = "solid" onClick = { handleStore } icon = { < IconSave / > } >
Save
< / Button >
< / div >
2025-07-23 08:47:26 +02:00
< / div >
2026-03-23 13:22:34 +01:00
< / TabPane >
< TabPane
tab = {
< span >
< IconHome size = "small" style = { { marginRight : 6 } } / >
User Settings
< / span >
}
itemKey = "userSettings"
>
< div className = "generalSettings__tab-content" >
< SegmentPart
name = "Home Address"
helpText = "Used to calculate distances between your location and each listing. Updating this recalculates distances for all active listings."
>
< AutoComplete
data = { dataSource }
value = { address }
showClear
onChange = { ( v ) => setAddress ( v ) }
onSearch = { searchAddress }
placeholder = "Enter your home address"
style = { { width : '100%' } }
/ >
{ coords && coords . lat === - 1 && (
< Banner
type = "danger"
description = "Address found but could not be geocoded accurately."
closeIcon = { null }
style = { { marginTop : 8 } }
/ >
) }
< / SegmentPart >
< SegmentPart
2026-04-07 19:53:40 +02:00
name = "Provider Details"
helpText = "Fetch additional details (description, attributes, agent info) for listings. Needs an extra API call per listing."
2026-03-23 13:22:34 +01:00
>
< Banner
type = "warning"
2026-04-07 19:53:40 +02:00
description = "Enabling this significantly increases API requests to providers that have implemented this feature, raising the chance of rate limiting or blocking. Use at your own risk."
2026-03-23 13:22:34 +01:00
closeIcon = { null }
style = { { marginBottom : 12 } }
/ >
2026-04-07 19:53:40 +02:00
< Select
multiple
style = { { width : '100%' } }
value = { Array . isArray ( providerDetails ) ? providerDetails : [ ] }
optionList = { ( allProviders ? ? [ ] ) . map ( ( p ) => ( { label : p . name , value : p . id } ) ) }
placeholder = "Select providers to fetch details from..."
onChange = { async ( selected ) => {
try {
await actions . userSettings . setProviderDetails ( selected ) ;
Toast . success ( 'Provider details setting updated.' ) ;
} catch {
Toast . error ( 'Failed to update setting.' ) ;
}
} }
/ >
2026-03-23 13:22:34 +01:00
< / SegmentPart >
< div className = "generalSettings__save-row" >
< Button
icon = { < IconSave / > }
theme = "solid"
type = "primary"
onClick = { handleSaveUserSettings }
loading = { saving }
>
Save
< / Button >
< / div >
< / div >
< / TabPane >
2026-04-07 19:53:40 +02:00
< TabPane
tab = {
< span >
< IconFolder size = "small" style = { { marginRight : 6 } } / >
Backup & Restore
< / span >
}
itemKey = "backup"
>
< div className = "generalSettings__tab-content" >
< SegmentPart
name = "Backup & Restore"
helpText = "Download a zipped backup of your database or restore from a backup zip."
>
< div style = { { display : 'flex' , gap : '0.5rem' , alignItems : 'center' , flexWrap : 'wrap' } } >
< Button theme = "solid" icon = { < IconSave / > } onClick = { handleDownloadBackup } >
Download Backup
< / Button >
< input
type = "file"
accept = ".zip,application/zip"
ref = { fileInputRef }
style = { { display : 'none' } }
onChange = { handleSelectRestoreFile }
/ >
< Button onClick = { handleOpenFilePicker } theme = "light" icon = { < IconFolder / > } >
Restore from Zip
< / Button >
< / div >
< / SegmentPart >
< / div >
< / TabPane >
2026-03-23 13:22:34 +01:00
< / Tabs >
< / >
2025-07-23 08:47:26 +02:00
) }
2026-03-23 13:22:34 +01:00
2025-12-17 15:48:56 +01:00
{ restoreModalVisible && (
< Modal
title = "Restore database"
visible = { restoreModalVisible }
onCancel = { ( ) => setRestoreModalVisible ( false ) }
onOk = { ( ) => performRestore ( ! precheckInfo ? . compatible ) }
okText = { precheckInfo ? . compatible ? 'Restore now' : 'Restore anyway' }
okType = { precheckInfo ? . compatible ? 'primary' : 'danger' }
confirmLoading = { restoreBusy }
>
{ precheckInfo ? . severity === 'danger' && (
< Banner
type = "danger"
fullMode = { false }
closeIcon = { null }
title = { < div style = { { fontWeight : 600 , fontSize : '14px' } } > Problem detected < / div > }
description = { < div > { precheckInfo ? . message } < / div > }
/ >
) }
{ precheckInfo ? . severity === 'warning' && (
< Banner
type = "warning"
fullMode = { false }
closeIcon = { null }
title = { < div style = { { fontWeight : 600 , fontSize : '14px' } } > Automatic migrations will be applied < / div > }
description = { < div > { precheckInfo ? . message } < / div > }
/ >
) }
{ precheckInfo ? . severity === 'info' && (
< Banner
type = "success"
fullMode = { false }
closeIcon = { null }
title = { < div style = { { fontWeight : 600 , fontSize : '14px' } } > Backup is compatible < / div > }
description = { < div > { precheckInfo ? . message } < / div > }
/ >
) }
< div style = { { marginTop : '0.5rem' , fontSize : '12px' , color : 'var(--semi-color-text-2)' } } >
Backup migration : { precheckInfo ? . backupMigration ? ? 'unknown' } | Required migration : { ' ' }
{ precheckInfo ? . requiredMigration ? ? 'unknown' }
< / div >
< / Modal >
) }
2025-07-23 08:47:26 +02:00
< / div >
) ;
2021-05-30 09:37:45 +02:00
} ;
export default GeneralSettings ;