UI improvements (#283)

* ui-improvements

* improving dashboard and settings

* improve job overview

* improving job card

* improving grid view of listings+

* restructuring settings

* next release version
This commit is contained in:
Christian Kellner
2026-03-23 13:22:34 +01:00
committed by GitHub
parent a1289acf15
commit 8c039f0026
16 changed files with 997 additions and 780 deletions

View File

@@ -3,31 +3,38 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React from 'react';
import React, { useEffect, useState, useMemo } from 'react';
import { useActions, useSelector } from '../../services/state/store';
import { useActions, useSelector, useIsLoading } from '../../services/state/store';
import { Divider, TimePicker, Button, Checkbox, Input, Modal } from '@douyinfe/semi-ui-19';
import {
Tabs,
TabPane,
TimePicker,
Button,
Checkbox,
Input,
Modal,
Typography,
AutoComplete,
Switch,
Banner,
} from '@douyinfe/semi-ui-19';
import { InputNumber } from '@douyinfe/semi-ui-19';
import { xhrPost } from '../../services/xhr';
import { xhrPost, xhrGet } from '../../services/xhr';
import { Toast } from '@douyinfe/semi-ui-19';
import { SegmentPart } from '../../components/segment/SegmentPart';
import { Banner, Toast } from '@douyinfe/semi-ui-19';
import {
downloadBackup as downloadBackupZip,
precheckRestore as clientPrecheckRestore,
restore as clientRestore,
} from '../../services/backupRestoreClient';
import {
IconSave,
IconCalendar,
IconRefresh,
IconSignal,
IconLineChartStroked,
IconSearch,
IconFolder,
} from '@douyinfe/semi-icons';
import { IconSave, IconRefresh, IconSignal, IconHome, IconFolder } from '@douyinfe/semi-icons';
import debounce from 'lodash/debounce';
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()}`;
@@ -63,6 +70,14 @@ const GeneralSettings = function GeneralSettings() {
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 immoscoutDetails = useSelector((state) => state.userSettings.settings.immoscout_details);
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();
@@ -86,6 +101,11 @@ const GeneralSettings = function GeneralSettings() {
init();
}, [settings]);
useEffect(() => {
setAddress(homeAddress?.address || '');
setCoords(homeAddress?.coords || null);
}, [homeAddress]);
const nullOrEmpty = (val) => val == null || val.length === 0;
const handleStore = async () => {
@@ -177,7 +197,6 @@ const GeneralSettings = function GeneralSettings() {
if (!file) return;
setSelectedRestoreFile(file);
await precheckRestore(file);
// reset the input to allow same file re-select
ev.target.value = '';
},
[precheckRestore],
@@ -189,180 +208,280 @@ const GeneralSettings = function GeneralSettings() {
}
}, []);
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 (
<div>
<div className="generalSettings">
{!loading && (
<React.Fragment>
<div>
<SegmentPart
name="Interval"
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."
Icon={IconRefresh}
<>
<Tabs type="line">
<TabPane
tab={
<span>
<IconSignal size="small" style={{ marginRight: 6 }} />
System
</span>
}
itemKey="system"
>
<InputNumber
min={5}
max={1440}
placeholder="Interval in minutes"
value={interval}
formatter={(value) => `${value}`.replace(/\D/g, '')}
onChange={(value) => setInterval(value)}
suffix={'minutes'}
/>
</SegmentPart>
<Divider margin="1rem" />
<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 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>
</div>
</SegmentPart>
<Divider margin="1rem" />
<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" />
<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>
}
/>
</TabPane>
<Input
type="text"
placeholder="Select folder"
value={sqlitePath}
onChange={(value) => {
setSqlitePath(value);
}}
/>
</SegmentPart>
<Divider margin="1rem" />
<SegmentPart
name="Working hours"
helpText="During these hours, Fredy will search for new apartments. If nothing is configured, Fredy will search around the clock."
Icon={IconCalendar}
<TabPane
tab={
<span>
<IconRefresh size="small" style={{ marginRight: 6 }} />
Execution
</span>
}
itemKey="execution"
>
<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 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));
}}
/>
</div>
</SegmentPart>
<div className="generalSettings__save-row">
<Button type="primary" theme="solid" onClick={handleStore} icon={<IconSave />}>
Save
</Button>
</div>
</div>
</SegmentPart>
<Divider margin="1rem" />
</TabPane>
<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.
<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>
<Checkbox checked={analyticsEnabled} onChange={(e) => setAnalyticsEnabled(e.target.checked)}>
{' '}
Enabled
</Checkbox>
</SegmentPart>
<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>
<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.
<SegmentPart
name="ImmoScout Details"
helpText="Fetch additional details (description, attributes, agent info) for ImmoScout listings. Makes an extra API call per listing."
>
<Banner
type="warning"
description="Enabling this significantly increases API requests to ImmoScout, raising the chance of rate limiting or blocking. Use at your own risk."
closeIcon={null}
style={{ marginBottom: 12 }}
/>
<div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<Switch
checked={!!immoscoutDetails}
onChange={async (checked) => {
try {
await actions.userSettings.setImmoscoutDetails(checked);
Toast.success('ImmoScout details setting updated.');
} catch {
Toast.error('Failed to update setting.');
}
}}
/>
<Text>Fetch detailed ImmoScout listings</Text>
</div>
}
/>
</SegmentPart>
<Checkbox checked={demoMode} onChange={(e) => setDemoMode(e.target.checked)}>
{' '}
Enabled
</Checkbox>
</SegmentPart>
<Divider margin="1rem" />
<Button type="primary" theme="solid" onClick={handleStore} icon={<IconSave />}>
Save
</Button>
</div>
</React.Fragment>
<div className="generalSettings__save-row">
<Button
icon={<IconSave />}
theme="solid"
type="primary"
onClick={handleSaveUserSettings}
loading={saving}
>
Save
</Button>
</div>
</div>
</TabPane>
</Tabs>
</>
)}
{restoreModalVisible && (
<Modal
title="Restore database"