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
* /
2021-05-30 09:37:45 +02:00
import React from 'react' ;
2025-09-18 20:09:11 +02:00
import { useActions , useSelector } from '../../services/state/store' ;
2021-05-30 09:37:45 +02:00
2025-12-17 15:48:56 +01:00
import { Divider , TimePicker , Button , Checkbox , Input , Modal } from '@douyinfe/semi-ui' ;
2025-07-23 08:47:26 +02:00
import { InputNumber } from '@douyinfe/semi-ui' ;
import { xhrPost } from '../../services/xhr' ;
import { SegmentPart } from '../../components/segment/SegmentPart' ;
import { Banner , Toast } from '@douyinfe/semi-ui' ;
2025-12-17 15:48:56 +01:00
import {
downloadBackup as downloadBackupZip ,
precheckRestore as clientPrecheckRestore ,
restore as clientRestore ,
} from '../../services/backupRestoreClient' ;
2025-07-23 08:47:26 +02:00
import {
IconSave ,
IconCalendar ,
IconRefresh ,
IconSignal ,
IconLineChartStroked ,
IconSearch ,
2025-09-18 15:38:23 +02:00
IconFolder ,
2025-07-23 08:47:26 +02:00
} from '@douyinfe/semi-icons' ;
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
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
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 ) ;
// reset the input to allow same file re-select
ev . target . value = '' ;
} ,
[ precheckRestore ] ,
) ;
const handleOpenFilePicker = React . useCallback ( ( ) => {
if ( fileInputRef . current ) {
fileInputRef . current . click ( ) ;
}
} , [ ] ) ;
2025-07-23 08:47:26 +02:00
return (
< div >
{ ! loading && (
< React.Fragment >
< div >
< SegmentPart
name = "Interval"
2025-09-08 08:30:45 +02:00
helpText = "Interval in minutes for running queries against the configured services. Do NOT go under 5 minutes as with a lower interval, your instance might be detected as a bot."
2025-07-23 08:47:26 +02:00
Icon = { IconRefresh }
>
< InputNumber
2025-09-08 08:30:45 +02:00
min = { 5 }
2025-07-23 08:47:26 +02:00
max = { 1440 }
placeholder = "Interval in minutes"
value = { interval }
formatter = { ( value ) => ` ${ value } ` . replace ( /\D/g , '' ) }
onChange = { ( value ) => setInterval ( value ) }
suffix = { 'minutes' }
/ >
< / SegmentPart >
< Divider margin = "1rem" / >
2025-12-17 15:48:56 +01:00
< SegmentPart
name = "Backup & Restore"
helpText = "Download a zipped backup of your database or restore it from a backup zip."
Icon = { IconSave }
>
< 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 >
< Divider margin = "1rem" / >
2025-07-23 08:47:26 +02:00
< SegmentPart name = "Port" helpText = "Port on which Fredy is running." Icon = { IconSignal } >
< InputNumber
min = { 0 }
max = { 99999 }
placeholder = "Port"
value = { port }
formatter = { ( value ) => ` ${ value } ` . replace ( /\D/g , '' ) }
onChange = { ( value ) => setPort ( value ) }
/ >
< / SegmentPart >
< Divider margin = "1rem" / >
2025-09-18 15:38:23 +02:00
< SegmentPart
name = "SQLite Database path"
helpText = "The directory where Fredy stores its SQLite database files."
Icon = { IconFolder }
>
< Banner
fullMode = { false }
type = "warning"
closeIcon = { null }
title = { < div style = { { fontWeight : 600 , fontSize : '14px' , lineHeight : '20px' } } > Warning < / div > }
style = { { marginBottom : '1rem' } }
description = {
< div >
Changing the path later may result in data loss .
< br / >
You < b > must < / b > restart Fredy immediately after changing this setting !
< / div >
}
/ >
< Input
type = "text"
placeholder = "Select folder"
value = { sqlitePath }
onChange = { ( value ) => {
setSqlitePath ( value ) ;
} }
/ >
< / SegmentPart >
< Divider margin = "1rem" / >
2025-07-23 08:47:26 +02:00
< SegmentPart
name = "Working hours"
2025-09-27 18:01:42 +02:00
helpText = "During these hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock."
2025-07-23 08:47:26 +02:00
Icon = { IconCalendar }
>
< 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 ) ) ;
} }
/ >
< / div >
< / SegmentPart >
< Divider margin = "1rem" / >
< SegmentPart name = "Analytics" helpText = "Insights into the usage of Fredy." Icon = { IconLineChartStroked } >
< Banner
fullMode = { false }
type = "info"
closeIcon = { null }
title = { < div style = { { fontWeight : 600 , fontSize : '14px' , lineHeight : '20px' } } > Explanation < / div > }
style = { { marginBottom : '1rem' } }
description = {
< div >
Analytics are disabled by default . If you choose to enable them , we will begin tracking the
following :
< br / >
< ul >
< li > Name of active provider ( e . g . Immoscout ) < / li >
< li > Name of active adapter ( e . g . Console ) < / li >
< li > language < / li >
< li > os < / li >
< li > node version < / li >
< li > arch < / li >
< / ul >
The data is sent anonymously and helps me understand which providers or adapters are being used the
most . In the end it helps me to improve fredy .
< / div >
}
/ >
< Checkbox checked = { analyticsEnabled } onChange = { ( e ) => setAnalyticsEnabled ( e . target . checked ) } >
{ ' ' }
Enabled
< / Checkbox >
< / SegmentPart >
< Divider margin = "1rem" / >
< SegmentPart name = "Demo Mode" helpText = "If enabled, Fredy runs in demo mode." Icon = { IconSearch } >
< Banner
fullMode = { false }
type = "info"
closeIcon = { null }
title = { < div style = { { fontWeight : 600 , fontSize : '14px' , lineHeight : '20px' } } > Explanation < / div > }
style = { { marginBottom : '1rem' } }
description = {
< div >
In demo mode , Fredy will not ( really ) search for any real estates . Fredy is in a lockdown mode . Also
all database files will be set back to the default values at midnight .
< / div >
}
/ >
< Checkbox checked = { demoMode } onChange = { ( e ) => setDemoMode ( e . target . checked ) } >
{ ' ' }
Enabled
< / Checkbox >
< / SegmentPart >
< Divider margin = "1rem" / >
2025-12-17 15:48:56 +01:00
< Button type = "primary" theme = "solid" onClick = { handleStore } icon = { < IconSave / > } >
2025-07-23 08:47:26 +02:00
Save
< / Button >
< / div >
< / React.Fragment >
) }
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 ;