/* * Copyright (c) 2026 by Christian Kellner. * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause */ import React, { useEffect, useState, useMemo } from 'react'; import { useActions, useSelector, useIsLoading } from '../../services/state/store'; import { Tabs, TabPane, TimePicker, Button, Checkbox, Input, Modal, AutoComplete, Select, Banner, } from '@douyinfe/semi-ui-19'; import { InputNumber } from '@douyinfe/semi-ui-19'; import { xhrPost, xhrGet } from '../../services/xhr'; import { Toast } from '@douyinfe/semi-ui-19'; import { SegmentPart } from '../../components/segment/SegmentPart'; import { downloadBackup as downloadBackupZip, precheckRestore as clientPrecheckRestore, restore as clientRestore, } from '../../services/backupRestoreClient'; import { IconSave, IconRefresh, IconSignal, IconHome, IconFolder } from '@douyinfe/semi-icons'; import { debounce } from '../../utils'; import './GeneralSettings.less'; function formatFromTimestamp(ts) { const date = new Date(ts); return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`; } function formatFromTBackend(time) { 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(); } const GeneralSettings = function GeneralSettings() { const actions = useActions(); 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); const [sqlitePath, setSqlitePath] = React.useState(null); 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); // User settings state const homeAddress = useSelector((state) => state.userSettings.settings.home_address); const providerDetails = useSelector((state) => state.userSettings.settings.provider_details); const allProviders = useSelector((state) => state.provider); const [address, setAddress] = useState(homeAddress?.address || ''); const [coords, setCoords] = useState(homeAddress?.coords || null); const saving = useIsLoading(actions.userSettings.setHomeAddress); const [dataSource, setDataSource] = useState([]); React.useEffect(() => { async function init() { await actions.generalSettings.getGeneralSettings(); setLoading(false); } 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); setSqlitePath(settings?.sqlitepath); } init(); }, [settings]); useEffect(() => { setAddress(homeAddress?.address || ''); setCoords(homeAddress?.coords || null); }, [homeAddress]); const nullOrEmpty = (val) => val == null || val.length === 0; const handleStore = async () => { if (nullOrEmpty(interval)) { Toast.error('Interval may not be empty.'); return; } if (nullOrEmpty(port)) { Toast.error('Port may not be empty.'); return; } if ( (!nullOrEmpty(workingHourFrom) && nullOrEmpty(workingHourTo)) || (nullOrEmpty(workingHourFrom) && !nullOrEmpty(workingHourTo)) ) { Toast.error('Working hours to and from must be set if either to or from has been set before.'); return; } if (nullOrEmpty(sqlitePath)) { Toast.error('SQLite db path cannot be empty.'); return; } try { await xhrPost('/api/admin/generalSettings', { interval, port, workingHours: { from: workingHourFrom, to: workingHourTo, }, demoMode, analyticsEnabled, sqlitepath: sqlitePath, }); } catch (exception) { console.error(exception); if (exception?.json?.message != null) { Toast.error(exception.json.message); } else { Toast.error('Error while trying to store settings.'); } return; } Toast.success('Settings stored successfully. We will reload your browser in 3 seconds.'); setTimeout(() => { location.reload(); }, 3000); }; 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(); } }, []); 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); }; return (
{!loading && ( <> System } itemKey="system" >
`${value}`.replace(/\D/g, '')} onChange={(value) => setPort(value)} style={{ maxWidth: 160 }} /> setSqlitePath(value)} /> setAnalyticsEnabled(e.target.checked)}> Enable analytics setDemoMode(e.target.checked)}> Enable demo mode
Execution } itemKey="execution" >
`${value}`.replace(/\D/g, '')} onChange={(value) => setInterval(value)} suffix={'minutes'} style={{ maxWidth: 200 }} />
{ setWorkingHourFrom(val == null ? null : formatFromTimestamp(val)); }} /> { setWorkingHourTo(val == null ? null : formatFromTimestamp(val)); }} />
User Settings } itemKey="userSettings" >
setAddress(v)} onSearch={searchAddress} placeholder="Enter your home address" style={{ width: '100%' }} /> {coords && coords.lat === -1 && ( )}
)} {restoreModalVisible && ( setRestoreModalVisible(false)} onOk={() => performRestore(!precheckInfo?.compatible)} okText={precheckInfo?.compatible ? 'Restore now' : 'Restore anyway'} okType={precheckInfo?.compatible ? 'primary' : 'danger'} confirmLoading={restoreBusy} > {precheckInfo?.severity === 'danger' && ( Problem detected} description={
{precheckInfo?.message}
} /> )} {precheckInfo?.severity === 'warning' && ( Automatic migrations will be applied} description={
{precheckInfo?.message}
} /> )} {precheckInfo?.severity === 'info' && ( Backup is compatible} description={
{precheckInfo?.message}
} /> )}
Backup migration: {precheckInfo?.backupMigration ?? 'unknown'} | Required migration:{' '} {precheckInfo?.requiredMigration ?? 'unknown'}
)} ); }; export default GeneralSettings;