mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
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:
committed by
GitHub
parent
a1289acf15
commit
8c039f0026
@@ -4,7 +4,7 @@
|
||||
*/
|
||||
|
||||
import React from 'react';
|
||||
import { Button, Col, Row, Toast } from '@douyinfe/semi-ui-19';
|
||||
import { Button, Col, Row, Toast, Typography } from '@douyinfe/semi-ui-19';
|
||||
import {
|
||||
IconTerminal,
|
||||
IconStar,
|
||||
@@ -22,7 +22,6 @@ import KpiCard from '../../components/cards/KpiCard.jsx';
|
||||
import PieChartCard from '../../components/cards/PieChartCard.jsx';
|
||||
|
||||
import './Dashboard.less';
|
||||
import { SegmentPart } from '../../components/segment/SegmentPart.jsx';
|
||||
import { xhrPost } from '../../services/xhr.js';
|
||||
import { format } from '../../services/time/timeService.js';
|
||||
|
||||
@@ -35,129 +34,119 @@ export default function Dashboard() {
|
||||
|
||||
const kpis = dashboard?.kpis || { totalJobs: 0, totalListings: 0, providersUsed: 0 };
|
||||
const pieData = dashboard?.pie || [];
|
||||
const { Text } = Typography;
|
||||
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<Text className="dashboard__section-label">General</Text>
|
||||
<Row gutter={[16, 16]} className="dashboard__row">
|
||||
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
|
||||
<SegmentPart name="General" Icon={IconTerminal}>
|
||||
<Row gutter={[16, 16]} className="dashboard__row">
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Search Interval"
|
||||
value={`${dashboard?.general?.interval} min`}
|
||||
icon={<IconClock />}
|
||||
description="Time interval for job execution"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Last Search"
|
||||
valueFontSize="14px"
|
||||
value={
|
||||
dashboard?.general?.lastRun == null || dashboard?.general?.lastRun === 0
|
||||
? '---'
|
||||
: format(dashboard?.general?.lastRun)
|
||||
}
|
||||
icon={<IconDoubleChevronLeft />}
|
||||
description="Last execution timestamp"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Next Search"
|
||||
value={
|
||||
dashboard?.general?.nextRun == null || dashboard?.general?.nextRun === 0
|
||||
? '---'
|
||||
: format(dashboard?.general?.nextRun)
|
||||
}
|
||||
valueFontSize="14px"
|
||||
icon={<IconDoubleChevronRight />}
|
||||
description="Next execution timestamp"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard title="Search Now" icon={<IconSearch />} description="Run a search now">
|
||||
<Button
|
||||
size="small"
|
||||
style={{ marginTop: '.2rem' }}
|
||||
icon={<IconPlayCircle />}
|
||||
aria-label="Start now"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await xhrPost('/api/jobs/startAll', null);
|
||||
Toast.success('Successfully triggered Fredy search.');
|
||||
} catch {
|
||||
Toast.error('Failed to trigger search');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Search now
|
||||
</Button>
|
||||
</KpiCard>
|
||||
</Col>
|
||||
</Row>
|
||||
</SegmentPart>
|
||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||
<KpiCard
|
||||
title="Search Interval"
|
||||
value={`${dashboard?.general?.interval} min`}
|
||||
icon={<IconClock />}
|
||||
description="Time interval for job execution"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
|
||||
<SegmentPart name="Overview" Icon={IconStar}>
|
||||
<Row gutter={[16, 16]} className="dashboard__row">
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Jobs"
|
||||
color="blue"
|
||||
value={!kpis.totalJobs ? '---' : kpis.totalJobs}
|
||||
icon={<IconTerminal />}
|
||||
description="Total number of jobs"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Listings"
|
||||
color="orange"
|
||||
value={!kpis.totalListings ? '---' : kpis.totalListings}
|
||||
icon={<IconStarStroked />}
|
||||
description="Total listings found"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Active Listings"
|
||||
color="green"
|
||||
value={!kpis.numberOfActiveListings ? '---' : kpis.numberOfActiveListings}
|
||||
icon={<IconStar />}
|
||||
description="Total active listings"
|
||||
/>
|
||||
</Col>
|
||||
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
|
||||
<KpiCard
|
||||
title="Avg. Price"
|
||||
color="purple"
|
||||
value={`${
|
||||
!kpis.avgPriceOfListings
|
||||
? '---'
|
||||
: new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(kpis.avgPriceOfListings)
|
||||
}`}
|
||||
icon={<IconNoteMoney />}
|
||||
description="Avg. Price of listings"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
</SegmentPart>
|
||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||
<KpiCard
|
||||
title="Last Search"
|
||||
valueFontSize="14px"
|
||||
value={
|
||||
dashboard?.general?.lastRun == null || dashboard?.general?.lastRun === 0
|
||||
? '---'
|
||||
: format(dashboard?.general?.lastRun)
|
||||
}
|
||||
icon={<IconDoubleChevronLeft />}
|
||||
description="Last execution timestamp"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||
<KpiCard
|
||||
title="Next Search"
|
||||
value={
|
||||
dashboard?.general?.nextRun == null || dashboard?.general?.nextRun === 0
|
||||
? '---'
|
||||
: format(dashboard?.general?.nextRun)
|
||||
}
|
||||
valueFontSize="14px"
|
||||
icon={<IconDoubleChevronRight />}
|
||||
description="Next execution timestamp"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||
<KpiCard title="Search Now" icon={<IconSearch />} description="Run a search now">
|
||||
<Button
|
||||
size="small"
|
||||
style={{ marginTop: '.2rem' }}
|
||||
icon={<IconPlayCircle />}
|
||||
aria-label="Start now"
|
||||
onClick={async () => {
|
||||
try {
|
||||
await xhrPost('/api/jobs/startAll', null);
|
||||
Toast.success('Successfully triggered Fredy search.');
|
||||
} catch {
|
||||
Toast.error('Failed to trigger search');
|
||||
}
|
||||
}}
|
||||
>
|
||||
Search now
|
||||
</Button>
|
||||
</KpiCard>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<SegmentPart
|
||||
name="Provider Insights"
|
||||
Icon={IconStar}
|
||||
helpText="Percentage of found listings over all providers"
|
||||
className="dashboard__provider-insights"
|
||||
>
|
||||
<Text className="dashboard__section-label">Overview</Text>
|
||||
<Row gutter={[16, 16]} className="dashboard__row">
|
||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||
<KpiCard
|
||||
title="Jobs"
|
||||
color="blue"
|
||||
value={!kpis.totalJobs ? '---' : kpis.totalJobs}
|
||||
icon={<IconTerminal />}
|
||||
description="Total number of jobs"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||
<KpiCard
|
||||
title="Listings"
|
||||
color="orange"
|
||||
value={!kpis.totalListings ? '---' : kpis.totalListings}
|
||||
icon={<IconStarStroked />}
|
||||
description="Total listings found"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||
<KpiCard
|
||||
title="Active Listings"
|
||||
color="green"
|
||||
value={!kpis.numberOfActiveListings ? '---' : kpis.numberOfActiveListings}
|
||||
icon={<IconStar />}
|
||||
description="Total active listings"
|
||||
/>
|
||||
</Col>
|
||||
<Col xs={24} sm={12} md={12} lg={6} xl={6}>
|
||||
<KpiCard
|
||||
title="Avg. Price"
|
||||
color="purple"
|
||||
value={`${
|
||||
!kpis.avgPriceOfListings
|
||||
? '---'
|
||||
: new Intl.NumberFormat('de-DE', {
|
||||
style: 'currency',
|
||||
currency: 'EUR',
|
||||
}).format(kpis.avgPriceOfListings)
|
||||
}`}
|
||||
icon={<IconNoteMoney />}
|
||||
description="Avg. Price of listings"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Text className="dashboard__section-label">Provider Insights</Text>
|
||||
<div className="dashboard__pie-wrapper">
|
||||
<PieChartCard data={pieData} />
|
||||
</SegmentPart>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,31 +3,32 @@
|
||||
flex-direction: column;
|
||||
flex: 1;
|
||||
|
||||
&__row {
|
||||
margin-bottom: 24px;
|
||||
flex-wrap: wrap;
|
||||
|
||||
.semi-col {
|
||||
margin-bottom: 0; // Handled by Row gutter
|
||||
}
|
||||
&__section-label {
|
||||
display: block;
|
||||
font-size: 11px !important;
|
||||
font-weight: 600 !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #5a6478 !important;
|
||||
margin-bottom: 10px;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
&__provider-insights {
|
||||
&__row {
|
||||
margin-bottom: 8px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__pie-wrapper {
|
||||
background: #23242a;
|
||||
border: 1px solid #37404e;
|
||||
|
||||
border-radius: 10px;
|
||||
padding: 24px;
|
||||
max-height: 320px;
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
margin: 0 !important;
|
||||
|
||||
.semi-card-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
max-height: 300px;
|
||||
|
||||
> * {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
justify-content: center;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -1,12 +1,17 @@
|
||||
.generalSettings {
|
||||
&__tab-content {
|
||||
padding: 20px 0;
|
||||
max-width: 860px;
|
||||
}
|
||||
|
||||
&__timePickerContainer {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 1rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
&__help {
|
||||
font-size: 11px;
|
||||
margin-left: 1rem;
|
||||
&__save-row {
|
||||
margin-top: 1.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,12 +9,13 @@ import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import { useSelector, useActions } from '../../services/state/store.js';
|
||||
import { distanceMeters, generateCircleCoords, getBoundsFromCenter, getBoundsFromCoords } from './mapUtils.js';
|
||||
import { Select, Space, Typography, Button, Popover, Divider, Switch, Banner, Toast } from '@douyinfe/semi-ui-19';
|
||||
import { IconFilter, IconLink } from '@douyinfe/semi-icons';
|
||||
import { Select, Typography, Switch, Banner, Toast } from '@douyinfe/semi-ui-19';
|
||||
import { IconLink } from '@douyinfe/semi-icons';
|
||||
import { IconDelete, IconEyeOpened } from '@douyinfe/semi-icons';
|
||||
|
||||
import no_image from '../../assets/no_image.jpg';
|
||||
import RangeSlider from 'react-range-slider-input';
|
||||
import _RangeSlider from 'react-range-slider-input';
|
||||
const RangeSlider = _RangeSlider?.default ?? _RangeSlider;
|
||||
import 'react-range-slider-input/dist/style.css';
|
||||
import './Map.less';
|
||||
import { xhrDelete } from '../../services/xhr.js';
|
||||
@@ -39,7 +40,6 @@ export default function MapView() {
|
||||
const jobs = useSelector((state) => state.jobsData.jobs);
|
||||
const [jobId, setJobId] = useState(null);
|
||||
const [priceRange, setPriceRange] = useState([0, 0]);
|
||||
const [showFilterBar, setShowFilterBar] = useState(false);
|
||||
const [distanceFilter, setDistanceFilter] = useState(0);
|
||||
|
||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||
@@ -92,10 +92,8 @@ export default function MapView() {
|
||||
};
|
||||
}, [navigate]);
|
||||
|
||||
// Get map instance reference after MapComponent renders
|
||||
useEffect(() => {
|
||||
if (mapContainer.current && !map.current) {
|
||||
// Wait for MapComponent to initialize the map
|
||||
const checkMapReady = () => {
|
||||
if (mapContainer.current?.map) {
|
||||
map.current = mapContainer.current.map;
|
||||
@@ -132,8 +130,6 @@ export default function MapView() {
|
||||
if (!map.current) return;
|
||||
|
||||
if (homeAddress?.coords) {
|
||||
// We only want to zoom/fly when distanceFilter OR homeAddress actually change,
|
||||
// not on every render. useEffect dependency array handles this.
|
||||
if (distanceFilter > 0) {
|
||||
const bounds = getBoundsFromCenter([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter);
|
||||
|
||||
@@ -290,7 +286,7 @@ export default function MapView() {
|
||||
|
||||
const popup = new maplibregl.Popup({ offset: 25 }).setHTML(popupContent);
|
||||
|
||||
let color = '#3FB1CE'; // Default blue-ish
|
||||
let color = '#3FB1CE';
|
||||
if (distanceFilter > 0 && homeAddress?.coords) {
|
||||
const dist = distanceMeters(
|
||||
homeAddress.coords.lat,
|
||||
@@ -315,114 +311,17 @@ export default function MapView() {
|
||||
|
||||
return (
|
||||
<div className="map-view-container">
|
||||
<div className="listingsGrid__searchbar map-filter-bar">
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem', flexGrow: 1 }}>
|
||||
<Text strong>Map View</Text>
|
||||
<Select placeholder="Style" style={{ width: 120 }} value={style} onChange={(val) => setMapStyle(val)}>
|
||||
<Select.Option value="STANDARD">Standard</Select.Option>
|
||||
<Select.Option value="SATELLITE">Satellite</Select.Option>
|
||||
</Select>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem', marginLeft: '1rem' }}>
|
||||
<Text strong>3D Buildings</Text>
|
||||
<Switch size="small" checked={show3dBuildings} onChange={(v) => setShow3dBuildings(v)} />
|
||||
</div>
|
||||
</div>
|
||||
<Popover content="Filter Results" style={{ color: 'white', padding: '.5rem' }}>
|
||||
<div>
|
||||
<Button
|
||||
icon={<IconFilter />}
|
||||
onClick={() => {
|
||||
setShowFilterBar(!showFilterBar);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
{showFilterBar && (
|
||||
<div className="listingsGrid__toolbar">
|
||||
<Space wrap style={{ marginBottom: '1rem' }}>
|
||||
<div className="listingsGrid__toolbar__card">
|
||||
<div>
|
||||
<Text strong>Filter by:</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '.3rem', alignItems: 'center' }}>
|
||||
<Select
|
||||
placeholder="Job"
|
||||
showClear
|
||||
style={{ width: 150 }}
|
||||
onChange={(val) => {
|
||||
setJobId(val);
|
||||
}}
|
||||
value={jobId}
|
||||
>
|
||||
{jobs?.map((j) => (
|
||||
<Select.Option key={j.id} value={j.id}>
|
||||
{j.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Divider layout="vertical" />
|
||||
<div className="listingsGrid__toolbar__card">
|
||||
<div>
|
||||
<Text strong>Distance:</Text>
|
||||
</div>
|
||||
<div style={{ display: 'flex', gap: '.3rem', alignItems: 'center' }}>
|
||||
<Select
|
||||
placeholder="Distance"
|
||||
style={{ width: 100 }}
|
||||
onChange={(val) => {
|
||||
setDistanceFilter(val);
|
||||
}}
|
||||
value={distanceFilter}
|
||||
>
|
||||
<Select.Option value={0}>---</Select.Option>
|
||||
<Select.Option value={5}>5 km</Select.Option>
|
||||
<Select.Option value={10}>10 km</Select.Option>
|
||||
<Select.Option value={15}>15 km</Select.Option>
|
||||
<Select.Option value={20}>20 km</Select.Option>
|
||||
<Select.Option value={25}>25 km</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<Divider layout="vertical" />
|
||||
<div className="listingsGrid__toolbar__card">
|
||||
<div>
|
||||
<Text strong>Price Range (€):</Text>
|
||||
</div>
|
||||
<div style={{ width: 250, padding: '0 10px' }}>
|
||||
<div className="map__rangesliderLabels">
|
||||
<span>{priceRange[0]} €</span>
|
||||
<span>{priceRange[1]} €</span>
|
||||
</div>
|
||||
<RangeSlider
|
||||
min={0}
|
||||
max={getMaxPrice()}
|
||||
step={100}
|
||||
value={priceRange}
|
||||
onInput={(val) => {
|
||||
setPriceRange(val);
|
||||
}}
|
||||
tipFormatter={(val) => `${val} €`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!homeAddress && (
|
||||
<Banner
|
||||
fullMode={true}
|
||||
type="warning"
|
||||
bordered
|
||||
closeIcon={null}
|
||||
style={{ marginBottom: '8px' }}
|
||||
description={
|
||||
<span>
|
||||
You have not set your home address yet. Please do so in the <Link to="/userSettings">user settings</Link>{' '}
|
||||
to use the distance filter.
|
||||
No home address set. Configure it in <Link to="/userSettings">user settings</Link> to use the distance
|
||||
filter.
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
@@ -433,10 +332,103 @@ export default function MapView() {
|
||||
type="info"
|
||||
bordered
|
||||
closeIcon={null}
|
||||
description="Keep in mind, only listings with proper adresses are being shown on this map."
|
||||
style={{ marginBottom: '8px' }}
|
||||
description="Only listings with valid addresses are shown on this map."
|
||||
/>
|
||||
|
||||
<Map mapContainerRef={mapContainer} style={style} show3dBuildings={show3dBuildings} onMapReady={handleMapReady} />
|
||||
<div className="map-view-container__map-wrapper">
|
||||
<Map
|
||||
mapContainerRef={mapContainer}
|
||||
style={style}
|
||||
show3dBuildings={show3dBuildings}
|
||||
onMapReady={handleMapReady}
|
||||
/>
|
||||
|
||||
{/* Floating filter panel */}
|
||||
<div className="map-view-container__floating-panel">
|
||||
<div className="map-view-container__panel-row">
|
||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||
Job
|
||||
</Text>
|
||||
<Select
|
||||
placeholder="All jobs"
|
||||
showClear
|
||||
size="small"
|
||||
onChange={(val) => setJobId(val)}
|
||||
value={jobId}
|
||||
style={{ width: 160 }}
|
||||
>
|
||||
{jobs?.map((j) => (
|
||||
<Select.Option key={j.id} value={j.id}>
|
||||
{j.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="map-view-container__panel-row">
|
||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||
Distance
|
||||
</Text>
|
||||
<Select
|
||||
placeholder="None"
|
||||
size="small"
|
||||
onChange={(val) => setDistanceFilter(val)}
|
||||
value={distanceFilter}
|
||||
style={{ width: 100 }}
|
||||
>
|
||||
<Select.Option value={0}>None</Select.Option>
|
||||
<Select.Option value={5}>5 km</Select.Option>
|
||||
<Select.Option value={10}>10 km</Select.Option>
|
||||
<Select.Option value={15}>15 km</Select.Option>
|
||||
<Select.Option value={20}>20 km</Select.Option>
|
||||
<Select.Option value={25}>25 km</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="map-view-container__panel-row">
|
||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||
Price (€)
|
||||
</Text>
|
||||
<div className="map-view-container__price-slider">
|
||||
<div className="map__rangesliderLabels">
|
||||
<span>{priceRange[0]}</span>
|
||||
<span>{priceRange[1]}</span>
|
||||
</div>
|
||||
<RangeSlider
|
||||
min={0}
|
||||
max={getMaxPrice()}
|
||||
step={100}
|
||||
value={priceRange}
|
||||
onInput={(val) => setPriceRange(val)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="map-view-container__panel-row">
|
||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||
Style
|
||||
</Text>
|
||||
<Select size="small" value={style} onChange={(val) => setMapStyle(val)} style={{ width: 110 }}>
|
||||
<Select.Option value="STANDARD">Standard</Select.Option>
|
||||
<Select.Option value="SATELLITE">Satellite</Select.Option>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="map-view-container__panel-row">
|
||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||
3D Buildings
|
||||
</Text>
|
||||
<Switch
|
||||
size="small"
|
||||
checked={show3dBuildings}
|
||||
onChange={(v) => setShow3dBuildings(v)}
|
||||
disabled={style === 'SATELLITE'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ListingDeletionModal
|
||||
visible={deleteModalVisible}
|
||||
onConfirm={confirmListingDeletion}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
/*
|
||||
* Copyright (c) 2025 by Christian Kellner.
|
||||
* Copyright (c) 2026 by Christian Kellner.
|
||||
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
|
||||
*/
|
||||
|
||||
@@ -9,18 +9,48 @@
|
||||
height: 100%;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
.map-filter-bar {
|
||||
margin-bottom: 1rem;
|
||||
&__map-wrapper {
|
||||
position: relative;
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
&__floating-panel {
|
||||
position: absolute;
|
||||
top: 12px;
|
||||
right: 12px;
|
||||
z-index: 10;
|
||||
background: rgba(13, 15, 20, 0.85);
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid #262a3a;
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
min-width: 220px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__panel-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
&__price-slider {
|
||||
width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
.map-container {
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--semi-color-border);
|
||||
border: 1px solid #262a3a;
|
||||
}
|
||||
|
||||
.map-popup-content {
|
||||
@@ -126,7 +156,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Override MapLibre default popup styles to match application theme */
|
||||
/* Override MapLibre default popup styles */
|
||||
.maplibregl-popup-content {
|
||||
background-color: var(--semi-color-bg-1) !important;
|
||||
color: var(--semi-color-text-0) !important;
|
||||
@@ -140,21 +170,26 @@
|
||||
}
|
||||
|
||||
.map {
|
||||
&__rangesliderLabels{
|
||||
color: white;
|
||||
&__rangesliderLabels {
|
||||
color: #94a3b8;
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: .3rem;
|
||||
font-size: .7rem;
|
||||
}
|
||||
}
|
||||
|
||||
.range-slider .range-slider__thumb {
|
||||
position: absolute;
|
||||
z-index: 3;
|
||||
top: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
transform: translate(-50%, -50%);
|
||||
border-radius: 50%;
|
||||
background: #2196f3;
|
||||
}
|
||||
background: #0ab5b3;
|
||||
}
|
||||
|
||||
.range-slider .range-slider__range {
|
||||
background: #0ab5b3;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user