Compare commits

...

5 Commits

Author SHA1 Message Date
Christian Kellner
8c039f0026 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
2026-03-23 13:22:34 +01:00
orangecoding
a1289acf15 fixing some docker issues 2026-03-22 09:41:20 +01:00
orangecoding
8501fc7266 upgrading dependencies 2026-03-21 08:09:15 +01:00
orangecoding
4960846cd7 fixing docker run 2026-03-21 08:08:52 +01:00
orangecoding
3ed17f4442 fixing broken puppeteer providers in docker caused by alpine chromium 146 crashing / switched to debian slim with puppeteer's own chrome for testing / dropped 2-stage build / run as non-root / purge build tools after install, improve docker-test.sh to verify it all works. That's it. ;) 2026-03-20 19:19:20 +01:00
21 changed files with 1128 additions and 867 deletions

View File

@@ -62,6 +62,7 @@ jobs:
- name: Test container with docker compose - name: Test container with docker compose
run: | run: |
echo "Starting container with docker compose..." echo "Starting container with docker compose..."
mkdir -p ./db ./conf && chmod 777 ./db ./conf
docker compose up --build -d docker compose up --build -d
echo "Waiting for container to be ready (60 seconds for start_period)..." echo "Waiting for container to be ready (60 seconds for start_period)..."
sleep 60 sleep 60

View File

@@ -1,70 +1,55 @@
# ================================ FROM node:22-slim
# Stage 1: Build stage
# ================================
FROM node:22-alpine AS builder
WORKDIR /build ARG TARGETARCH
# Install build dependencies needed for native modules (better-sqlite3) # System deps for Chrome for Testing + build tools for native modules (better-sqlite3)
RUN apk add --no-cache python3 make g++ # On ARM64 we also install system Chromium (Chrome for Testing has no ARM64 binary)
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates fonts-liberation libasound2 \
libatk-bridge2.0-0 libatk1.0-0 libcups2 libdbus-1-3 \
libdrm2 libgbm1 libgtk-3-0 libnspr4 libnss3 \
libx11-xcb1 libxcomposite1 libxdamage1 libxrandr2 xdg-utils \
python3 make g++ \
&& if [ "$TARGETARCH" = "arm64" ]; then apt-get install -y --no-install-recommends chromium; fi \
&& rm -rf /var/lib/apt/lists/* \
&& mkdir -p /db /conf /fredy
WORKDIR /fredy
ENV NODE_ENV=production \
IS_DOCKER=true
# Copy package files first for better layer caching
COPY package.json yarn.lock ./ COPY package.json yarn.lock ./
# Install all dependencies (including devDependencies for building) # Install dependencies and purge build tools (only needed to compile better-sqlite3)
RUN yarn config set network-timeout 600000 \ RUN yarn config set network-timeout 600000 \
&& yarn --frozen-lockfile && yarn --frozen-lockfile \
&& yarn cache clean
# on arm64 use the system Chromium installed above
RUN if [ "$TARGETARCH" != "arm64" ]; then npx puppeteer browsers install chrome; fi
# Purge build tools now that native modules are compiled
RUN apt-get purge -y python3 make g++ \
&& apt-get autoremove -y \
&& rm -rf /var/lib/apt/lists/*
# Copy source files needed for build
COPY index.html vite.config.js ./ COPY index.html vite.config.js ./
COPY ui ./ui COPY ui ./ui
COPY lib ./lib COPY lib ./lib
# Build frontend assets
RUN yarn build:frontend RUN yarn build:frontend
# ================================
# Stage 2: Production stage
# ================================
FROM node:22-alpine
WORKDIR /fredy
# Install Chromium and curl (for healthcheck)
# Using Alpine's chromium package which is much smaller
RUN apk add --no-cache chromium curl
ENV NODE_ENV=production \
IS_DOCKER=true \
PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
# Install build dependencies for native modules, then remove them after yarn install
COPY package.json yarn.lock ./
RUN apk add --no-cache --virtual .build-deps python3 make g++ \
&& yarn config set network-timeout 600000 \
&& yarn --frozen-lockfile --production \
&& yarn cache clean \
&& apk del .build-deps
# Copy built frontend from builder stage
COPY --from=builder /build/ui/public ./ui/public
# Copy application source (only what's needed at runtime)
COPY index.js ./ COPY index.js ./
COPY index.html ./
COPY lib ./lib
# Prepare runtime directories and symlinks for data and config RUN ln -s /db /fredy/db \
RUN mkdir -p /db /conf \
&& chown 1000:1000 /db /conf \
&& chmod 777 /db /conf \
&& ln -s /db /fredy/db \
&& ln -s /conf /fredy/conf && ln -s /conf /fredy/conf
EXPOSE 9998 EXPOSE 9998
VOLUME /db VOLUME /db
VOLUME /conf VOLUME /conf
HEALTHCHECK --interval=30s --timeout=10s --start-period=30s --retries=3 \
CMD curl -f http://localhost:9998/ || exit 1
CMD ["node", "index.js"] CMD ["node", "index.js"]

View File

@@ -7,12 +7,72 @@ if [ "$(docker ps -aq -f name=fredy)" ]; then
docker rm fredy || true docker rm fredy || true
fi fi
# On Apple Silicon, force linux/amd64 to match production CI and avoid arm64/x86_64
# Chrome mismatch under Rosetta. On native Linux (amd64 or arm64) let Docker pick naturally. That took me fucking 1 hour to figure out.
PLATFORM=""
if [ "$(uname -m)" = "arm64" ] && [ "$(uname -s)" = "Darwin" ]; then
PLATFORM="linux/amd64"
fi
# Build image from local Dockerfile, forcing a fresh build without cache # Build image from local Dockerfile, forcing a fresh build without cache
docker build --no-cache -t fredy:local . if [ -n "$PLATFORM" ]; then
docker build --no-cache --platform "$PLATFORM" -t fredy:local .
else
docker build --no-cache -t fredy:local .
fi
# Run container with volumes and port mapping # Run container with volumes and port mapping
docker run -d --name fredy \ if [ -n "$PLATFORM" ]; then
-v fredy_conf:/conf \ docker run -d --name fredy --platform "$PLATFORM" -v fredy_conf:/conf -v fredy_db:/db -p 9998:9998 fredy:local
-v fredy_db:/db \ else
-p 9998:9998 \ docker run -d --name fredy -v fredy_conf:/conf -v fredy_db:/db -p 9998:9998 fredy:local
fredy:local fi
echo "Waiting for app to be ready..."
for i in $(seq 1 30); do
if docker exec fredy curl -sf http://localhost:9998/ > /dev/null 2>&1; then
echo "App is up"
break
fi
if [ "$i" = "30" ]; then
echo "App did not come up in time"
docker logs fredy
exit 1
fi
sleep 2
done
# Verify the DB is readable/writable via the API.
# /api/demo is unauthenticated and reads the settings table — if SQLite is broken this returns an error.
echo "Testing DB via API (/api/demo)..."
DEMO_RESPONSE=$(docker exec fredy curl -sf http://localhost:9998/api/demo 2>&1)
if echo "$DEMO_RESPONSE" | grep -q "demoMode"; then
echo "DB is readable (got demoMode from /api/demo)"
else
echo "DB check failed — unexpected response from /api/demo: $DEMO_RESPONSE"
docker logs fredy
exit 1
fi
# Verify Chrome launches without crashing.
# On amd64: Chrome for Testing lives in the puppeteer cache.
# On arm64: system Chromium is used instead.
echo "Testing Chrome..."
CHROME=$(docker exec fredy find /root/.cache/puppeteer /home -name chrome -type f 2>/dev/null | head -1)
if [ -z "$CHROME" ]; then
CHROME=$(docker exec fredy which chromium 2>/dev/null || true)
fi
if [ -z "$CHROME" ]; then
echo "Chrome/Chromium binary not found"
exit 1
fi
if docker exec fredy "$CHROME" --headless --no-sandbox --disable-gpu --dump-dom https://example.com 2>&1 | grep -q "<html"; then
echo "Chrome works"
else
echo "Chrome failed to render a page"
docker exec fredy "$CHROME" --headless --no-sandbox --disable-gpu --dump-dom https://example.com 2>&1 | head -20
exit 1
fi
echo ""
echo "All checks passed."

View File

@@ -47,12 +47,17 @@ export async function launchBrowser(url, options) {
removeUserDataDir = true; removeUserDataDir = true;
} }
// On ARM64 Docker, Chrome for Testing has no native binary — use system Chromium instead.
const executablePath =
options?.executablePath ||
(process.arch === 'arm64' && process.env.IS_DOCKER === 'true' ? '/usr/bin/chromium' : undefined);
const browser = await puppeteer.launch({ const browser = await puppeteer.launch({
headless: options?.puppeteerHeadless ?? true, headless: options?.puppeteerHeadless ?? true,
args: launchArgs, args: launchArgs,
timeout: options?.puppeteerTimeout || 45_000, timeout: options?.puppeteerTimeout || 45_000,
userDataDir, userDataDir,
executablePath: options?.executablePath, executablePath,
}); });
browser.__fredy_userDataDir = userDataDir; browser.__fredy_userDataDir = userDataDir;

View File

@@ -1,6 +1,6 @@
{ {
"name": "fredy", "name": "fredy",
"version": "20.0.6", "version": "20.1.0",
"description": "[F]ind [R]eal [E]states [d]amn eas[y].", "description": "[F]ind [R]eal [E]states [d]amn eas[y].",
"scripts": { "scripts": {
"prepare": "husky", "prepare": "husky",
@@ -77,7 +77,7 @@
"cookie-session": "2.1.1", "cookie-session": "2.1.1",
"handlebars": "4.7.8", "handlebars": "4.7.8",
"lodash": "4.17.23", "lodash": "4.17.23",
"maplibre-gl": "^5.20.2", "maplibre-gl": "^5.21.0",
"nanoid": "5.1.7", "nanoid": "5.1.7",
"node-cron": "^4.2.1", "node-cron": "^4.2.1",
"node-fetch": "3.3.2", "node-fetch": "3.3.2",
@@ -111,7 +111,7 @@
"@babel/preset-react": "7.28.5", "@babel/preset-react": "7.28.5",
"@eslint/js": "^10.0.1", "@eslint/js": "^10.0.1",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"eslint": "10.0.3", "eslint": "10.1.0",
"eslint-config-prettier": "10.1.8", "eslint-config-prettier": "10.1.8",
"eslint-plugin-react": "7.37.5", "eslint-plugin-react": "7.37.5",
"globals": "^17.4.0", "globals": "^17.4.0",

View File

@@ -8,7 +8,6 @@ import React, { useEffect } from 'react';
import InsufficientPermission from './components/permission/InsufficientPermission'; import InsufficientPermission from './components/permission/InsufficientPermission';
import PermissionAwareRoute from './components/permission/PermissionAwareRoute'; import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
import GeneralSettings from './views/generalSettings/GeneralSettings'; import GeneralSettings from './views/generalSettings/GeneralSettings';
import UserSettings from './views/userSettings/UserSettings';
import JobMutation from './views/jobs/mutation/JobMutation'; import JobMutation from './views/jobs/mutation/JobMutation';
import UserMutator from './views/user/mutation/UserMutator'; import UserMutator from './views/user/mutation/UserMutator';
import { useActions, useSelector } from './services/state/store'; import { useActions, useSelector } from './services/state/store';
@@ -127,15 +126,8 @@ export default function FredyApp() {
</PermissionAwareRoute> </PermissionAwareRoute>
} }
/> />
<Route path="/userSettings" element={<UserSettings />} /> <Route path="/userSettings" element={<Navigate to="/generalSettings" replace />} />
<Route <Route path="/generalSettings" element={<GeneralSettings />} />
path="/generalSettings"
element={
<PermissionAwareRoute currentUser={currentUser}>
<GeneralSettings />
</PermissionAwareRoute>
}
/>
<Route path="/" element={<Navigate to="/dashboard" replace />} /> <Route path="/" element={<Navigate to="/dashboard" replace />} />
</Routes> </Routes>

View File

@@ -1,12 +1,14 @@
@import './DashboardCardColors.less';
.dashboard-card { .dashboard-card {
width: 100%; width: 100%;
height: 140px; height: 140px;
margin-bottom: 16px; margin-bottom: 16px;
transition: transform 0.2s; transition: transform 0.2s, box-shadow 0.2s;
background-color: rgba(36, 36, 36, 0.9); background-color: #181b26;
backdrop-filter: blur(8px); border: 1px solid #232735;
border: 1px solid var(--semi-color-border); border-radius: 10px;
--pulse-color: rgba(255, 255, 255, 0.1); --pulse-color: rgba(255, 255, 255, 0.08);
position: relative; position: relative;
z-index: 1; z-index: 1;
overflow: visible; overflow: visible;
@@ -32,6 +34,14 @@
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
color: var(--card-accent, #94a3b8);
}
&__title {
color: var(--semi-color-text-2) !important;
font-size: 12px !important;
text-transform: uppercase;
letter-spacing: 0.05em;
} }
&__content { &__content {
@@ -41,32 +51,51 @@
&__value { &__value {
font-weight: 700; font-weight: 700;
margin-bottom: 4px; margin-bottom: 4px;
color: var(--semi-color-text-0); color: var(--card-accent, var(--semi-color-text-0));
}
&__desc {
color: var(--semi-color-text-3) !important;
} }
&.blue { &.blue {
--pulse-color: var(--semi-color-primary); --pulse-color: @color-blue-border;
box-shadow: 0 4px 20px -5px var(--pulse-color); --card-accent: @color-blue-text;
background-color: @color-blue-bg;
border-color: @color-blue-border;
box-shadow: 0 2px 16px -6px @color-blue-border;
} }
&.orange { &.orange {
--pulse-color: var(--semi-color-warning); --pulse-color: @color-orange-border;
box-shadow: 0 4px 20px -5px var(--pulse-color); --card-accent: @color-orange-text;
background-color: @color-orange-bg;
border-color: @color-orange-border;
box-shadow: 0 2px 16px -6px @color-orange-border;
} }
&.green { &.green {
--pulse-color: var(--semi-color-success); --pulse-color: @color-green-border;
box-shadow: 0 4px 20px -5px var(--pulse-color); --card-accent: @color-green-text;
background-color: @color-green-bg;
border-color: @color-green-border;
box-shadow: 0 2px 16px -6px @color-green-border;
} }
&.purple { &.purple {
--pulse-color: var(--semi-color-info); --pulse-color: @color-purple-border;
box-shadow: 0 4px 20px -5px var(--pulse-color); --card-accent: @color-purple-text;
background-color: @color-purple-bg;
border-color: @color-purple-border;
box-shadow: 0 2px 16px -6px @color-purple-border;
} }
&.gray { &.gray {
--pulse-color: rgba(255, 255, 255, 0.2); --pulse-color: @color-gray-border;
box-shadow: 0 4px 20px -5px var(--pulse-color); --card-accent: @color-gray-text;
background-color: @color-gray-bg;
border-color: @color-gray-border;
box-shadow: 0 2px 16px -6px @color-gray-border;
} }
} }
@@ -75,6 +104,6 @@
opacity: 0.1; opacity: 0.1;
} }
50% { 50% {
opacity: 0.5; opacity: 0.4;
} }
} }

View File

@@ -1,19 +1,19 @@
@color-blue-bg: rgba(0, 123, 255, 0.24); @color-blue-bg: rgba(96, 165, 250, 0.10);
@color-blue-border: #1E40AFFF; @color-blue-border: #3b6ea8;
@color-blue-text: #60a5fa; @color-blue-text: #60a5fa;
@color-orange-bg: rgba(250, 91, 5, 0.12); @color-orange-bg: rgba(251, 146, 60, 0.10);
@color-orange-border: #992f0c; @color-orange-border: #c2622a;
@color-orange-text: #FB923CFF; @color-orange-text: #fb923c;
@color-green-bg: rgba(38, 250, 5, 0.12); @color-green-bg: rgba(52, 211, 153, 0.10);
@color-green-border: #278832; @color-green-border: #2a8a61;
@color-green-text: #33f308; @color-green-text: #34d399;
@color-purple-bg: rgba(91, 3, 218, 0.38); @color-purple-bg: rgba(167, 139, 250, 0.10);
@color-purple-border: #7500c3; @color-purple-border: #6d4fc2;
@color-purple-text: #b15fff; @color-purple-text: #a78bfa;
@color-gray-bg: rgba(110, 110, 110, 0.38); @color-gray-bg: rgba(148, 163, 184, 0.10);
@color-gray-border: #807f7f; @color-gray-border: #323a47;
@color-gray-text: #bab9b9; @color-gray-text: #94a3b8;

View File

@@ -9,7 +9,6 @@ import {
Col, Col,
Row, Row,
Button, Button,
Space,
Typography, Typography,
Divider, Divider,
Switch, Switch,
@@ -20,6 +19,8 @@ import {
Pagination, Pagination,
Toast, Toast,
Empty, Empty,
Radio,
RadioGroup,
} from '@douyinfe/semi-ui-19'; } from '@douyinfe/semi-ui-19';
import { import {
IconAlertTriangle, IconAlertTriangle,
@@ -31,8 +32,10 @@ import {
IconBriefcase, IconBriefcase,
IconBell, IconBell,
IconSearch, IconSearch,
IconFilter,
IconPlusCircle, IconPlusCircle,
IconArrowUp,
IconArrowDown,
IconHome,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import ListingDeletionModal from '../../ListingDeletionModal.jsx'; import ListingDeletionModal from '../../ListingDeletionModal.jsx';
@@ -59,8 +62,6 @@ const JobGrid = () => {
const [sortDir, setSortDir] = useState('asc'); const [sortDir, setSortDir] = useState('asc');
const [freeTextFilter, setFreeTextFilter] = useState(null); const [freeTextFilter, setFreeTextFilter] = useState(null);
const [activityFilter, setActivityFilter] = useState(null); const [activityFilter, setActivityFilter] = useState(null);
const [showFilterBar, setShowFilterBar] = useState(false);
const [deleteModalVisible, setDeleteModalVisible] = useState(false); const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [pendingDeletion, setPendingDeletion] = useState(null); // { type: 'job'|'listings', jobId } const [pendingDeletion, setPendingDeletion] = useState(null); // { type: 'job'|'listings', jobId }
@@ -200,73 +201,45 @@ const JobGrid = () => {
return ( return (
<div className="jobGrid"> <div className="jobGrid">
<Space vertical align="start" style={{ width: '100%', marginBottom: '16px' }} spacing="medium"> <div className="jobGrid__topbar">
<Button type="primary" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}> <Button type="primary" icon={<IconPlusCircle />} onClick={() => navigate('/jobs/new')}>
New Job New Job
</Button> </Button>
<div className="jobGrid__searchbar" style={{ width: '100%' }}>
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} />
<Button
icon={<IconFilter />}
style={{ marginLeft: '8px' }}
onClick={() => {
setShowFilterBar(!showFilterBar);
}}
/>
</div>
</Space>
{showFilterBar && ( <Input
<div className="jobGrid__toolbar"> className="jobGrid__topbar__search"
<Space wrap style={{ marginBottom: '1rem' }}> prefix={<IconSearch />}
<div className="jobGrid__toolbar__card"> showClear
<div> placeholder="Search"
<Text strong>Filter by:</Text> onChange={handleFilterChange}
</div> />
<div style={{ display: 'flex', gap: '.3rem' }}>
<Select
placeholder="Status"
showClear
onChange={(val) => setActivityFilter(val)}
value={activityFilter}
style={{ width: 140 }}
>
<Select.Option value={true}>Active</Select.Option>
<Select.Option value={false}>Not Active</Select.Option>
</Select>
</div>
</div>
<Divider layout="vertical" />
<div className="jobGrid__toolbar__card">
<div>
<Text strong>Sort by:</Text>
</div>
<div style={{ display: 'flex', gap: '.3rem' }}>
<Select
placeholder="Sort By"
style={{ width: 160 }}
value={sortField}
onChange={(val) => setSortField(val)}
>
<Select.Option value="name">Name</Select.Option>
<Select.Option value="numberOfFoundListings">Number of Listings</Select.Option>
<Select.Option value="enabled">Status</Select.Option>
</Select>
<Select <RadioGroup
placeholder="Direction" type="button"
style={{ width: 120 }} buttonSize="middle"
value={sortDir} value={activityFilter === null ? 'all' : String(activityFilter)}
onChange={(val) => setSortDir(val)} onChange={(e) => {
> const v = e.target.value;
<Select.Option value="asc">Ascending</Select.Option> setActivityFilter(v === 'all' ? null : v === 'true');
<Select.Option value="desc">Descending</Select.Option> }}
</Select> >
</div> <Radio value="all">All</Radio>
</div> <Radio value="true">Active</Radio>
</Space> <Radio value="false">Inactive</Radio>
</div> </RadioGroup>
)}
<Select prefix="Sort by" style={{ width: 200 }} value={sortField} onChange={(val) => setSortField(val)}>
<Select.Option value="name">Name</Select.Option>
<Select.Option value="numberOfFoundListings">Number of Listings</Select.Option>
<Select.Option value="enabled">Status</Select.Option>
</Select>
<Button
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
/>
</div>
{(jobsData?.result || []).length === 0 && ( {(jobsData?.result || []).length === 0 && (
<Empty <Empty
@@ -278,78 +251,70 @@ const JobGrid = () => {
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{(jobsData?.result || []).map((job) => ( {(jobsData?.result || []).map((job) => (
<Col key={job.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}> <Col key={job.id} xs={24} sm={12} md={12} lg={8} xl={8} xxl={6}>
<Card <Card className="jobGrid__card" bodyStyle={{ padding: '16px' }}>
className="jobGrid__card" <div className="jobGrid__card__header">
bodyStyle={{ padding: '16px' }} <div className="jobGrid__card__name">
title={ <span className={`jobGrid__card__dot${job.enabled ? ' jobGrid__card__dot--active' : ''}`} />
<div className="jobGrid__header">
<Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title"> <Title heading={5} ellipsis={{ showTooltip: true }} className="jobGrid__title">
{job.name} {job.name}
</Title> </Title>
<div style={{ display: 'flex', alignItems: 'center' }}> </div>
{job.isOnlyShared && ( <div style={{ display: 'flex', alignItems: 'center', gap: 6, flexShrink: 0 }}>
<Popover {job.isOnlyShared && (
content={getPopoverContent( <Popover
'This job has been shared with you by another user, therefor it is read-only.', content={getPopoverContent(
)} 'This job has been shared with you by another user, therefor it is read-only.',
> )}
<div> >
<IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)', marginLeft: '8px' }} /> <div>
</div> <IconAlertTriangle style={{ color: 'rgba(var(--semi-yellow-7), 1)' }} />
</Popover> </div>
)} </Popover>
</div> )}
{job.running && ( {job.running && (
<Tag color="green" variant="light" size="small"> <Tag color="green" variant="light" size="small">
RUNNING RUNNING
</Tag> </Tag>
)} )}
</div> </div>
} </div>
>
<div className="jobGrid__content">
<Space vertical align="start" spacing={4} style={{ width: '100%', marginTop: 12 }}>
<div className="jobGrid__infoItem">
<Text type="secondary" icon={<IconSearch />} size="small">
Is active:
</Text>
<Switch
onChange={(checked) => onJobStatusChanged(job.id, checked)}
style={{ marginLeft: 'auto' }}
checked={job.enabled}
disabled={job.isOnlyShared}
size="small"
/>
</div>
<div className="jobGrid__infoItem">
<Text type="secondary" icon={<IconSearch />} size="small">
Listings:
</Text>
<Tag color="blue" size="small" style={{ marginLeft: 'auto' }}>
{job.numberOfFoundListings || 0}
</Tag>
</div>
<div className="jobGrid__infoItem">
<Text type="secondary" icon={<IconBriefcase />} size="small">
Providers:
</Text>
<Tag color="cyan" size="small" style={{ marginLeft: 'auto' }}>
{job.provider.length || 0}
</Tag>
</div>
<div className="jobGrid__infoItem">
<Text type="secondary" icon={<IconBell />} size="small">
Adapters:
</Text>
<Tag color="purple" size="small" style={{ marginLeft: 'auto' }}>
{job.notificationAdapter.length || 0}
</Tag>
</div>
</Space>
<Divider margin="12px" /> <div className="jobGrid__card__stats">
<div className="jobGrid__card__stat jobGrid__card__stat--blue">
<span className="jobGrid__card__stat__number">{job.numberOfFoundListings || 0}</span>
<span className="jobGrid__card__stat__label">
<IconHome size="small" /> Listings
</span>
</div>
<div className="jobGrid__card__stat jobGrid__card__stat--orange">
<span className="jobGrid__card__stat__number">{job.provider.length || 0}</span>
<span className="jobGrid__card__stat__label">
<IconBriefcase size="small" /> Providers
</span>
</div>
<div className="jobGrid__card__stat jobGrid__card__stat--purple">
<span className="jobGrid__card__stat__number">{job.notificationAdapter.length || 0}</span>
<span className="jobGrid__card__stat__label">
<IconBell size="small" /> Adapters
</span>
</div>
</div>
<Divider margin="12px" />
<div className="jobGrid__card__footer">
<div style={{ display: 'flex', alignItems: 'center', gap: 6 }}>
<Switch
onChange={(checked) => onJobStatusChanged(job.id, checked)}
checked={job.enabled}
disabled={job.isOnlyShared}
size="small"
/>
<Text type="secondary" size="small">
Active
</Text>
</div>
<div className="jobGrid__actions"> <div className="jobGrid__actions">
<Popover content={getPopoverContent('Run Job')}> <Popover content={getPopoverContent('Run Job')}>
<div> <div>

View File

@@ -1,3 +1,5 @@
@import '../../cards/DashboardCardColors.less';
.jobGrid { .jobGrid {
&__card { &__card {
height: 100%; height: 100%;
@@ -12,55 +14,137 @@
box-shadow: 0 0 15px -3px rgb(78 78 78 / 70%); box-shadow: 0 0 15px -3px rgb(78 78 78 / 70%);
background-color: rgba(36, 36, 36, 1); background-color: rgba(36, 36, 36, 1);
} }
}
&__searchbar { &__header {
display: flex; display: flex;
gap: .5rem; align-items: flex-start;
align-items: center; justify-content: space-between;
justify-content: space-between; gap: 8px;
margin-bottom: 1rem; margin-bottom: 16px;
} }
&__toolbar { &__name {
&__card { display: flex;
border-radius: var(--semi-border-radius-medium); align-items: center;
gap: 8px;
min-width: 0;
}
&__dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background-color: var(--semi-color-text-3);
&--active {
background-color: #21aa21;
}
}
&__stats {
display: flex;
gap: 8px;
}
&__stat {
flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: .3rem; align-items: center;
background: rgba(36, 36, 36, 0.9); background: rgba(255, 255, 255, 0.04);
backdrop-filter: blur(8px); border: 1px solid transparent;
padding: 0.5rem; border-radius: var(--semi-border-radius-small);
border: 1px solid var(--semi-color-border); padding: 10px 4px 8px;
&__number {
font-size: 22px;
font-weight: 600;
color: var(--semi-color-text-0);
line-height: 1.2;
}
&__label {
font-size: 11px;
color: var(--semi-color-text-3);
display: flex;
align-items: center;
gap: 3px;
margin-top: 4px;
}
&--blue {
background: @color-blue-bg;
border-color: @color-blue-border;
.jobGrid__card__stat__number { color: @color-blue-text; }
.jobGrid__card__stat__label { color: @color-blue-text; opacity: 0.7; }
}
&--orange {
background: @color-orange-bg;
border-color: @color-orange-border;
.jobGrid__card__stat__number { color: @color-orange-text; }
.jobGrid__card__stat__label { color: @color-orange-text; opacity: 0.7; }
}
&--purple {
background: @color-purple-bg;
border-color: @color-purple-border;
.jobGrid__card__stat__number { color: @color-purple-text; }
.jobGrid__card__stat__label { color: @color-purple-text; opacity: 0.7; }
}
}
&__footer {
display: flex;
align-items: center;
justify-content: space-between;
} }
} }
&__header { &__topbar {
display: flex; display: flex;
flex-wrap: nowrap;
align-items: center; align-items: center;
justify-content: space-between; gap: 8px;
margin-bottom: 16px;
.jobGrid__topbar__search {
flex: 1;
min-width: 0;
}
@media (max-width: 768px) {
flex-wrap: wrap;
.semi-button:first-child {
flex-shrink: 0;
}
.jobGrid__topbar__search {
flex: 1;
min-width: 160px;
}
.semi-radio-group {
flex: 1;
}
.semi-select {
flex: 1;
min-width: 100px;
width: auto !important;
}
}
} }
&__title { &__title {
margin-bottom: 0 !important; margin-bottom: 0 !important;
} }
&__infoItem {
display: flex;
align-items: center;
width: 100%;
.semi-typography {
display: flex;
align-items: center;
gap: 4px;
}
}
&__actions { &__actions {
display: flex; display: flex;
justify-content: space-between; gap: 6px;
gap: 8px;
} }
&__pagination { &__pagination {

View File

@@ -10,15 +10,15 @@ import {
Row, Row,
Image, Image,
Button, Button,
Space,
Typography, Typography,
Pagination, Pagination,
Toast, Toast,
Divider, Divider,
Input, Input,
Select, Select,
Popover,
Empty, Empty,
Radio,
RadioGroup,
} from '@douyinfe/semi-ui-19'; } from '@douyinfe/semi-ui-19';
import { import {
IconBriefcase, IconBriefcase,
@@ -30,9 +30,10 @@ import {
IconStar, IconStar,
IconStarStroked, IconStarStroked,
IconSearch, IconSearch,
IconFilter,
IconActivity, IconActivity,
IconEyeOpened, IconEyeOpened,
IconArrowUp,
IconArrowDown,
} from '@douyinfe/semi-icons'; } from '@douyinfe/semi-icons';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import ListingDeletionModal from '../../ListingDeletionModal.jsx'; import ListingDeletionModal from '../../ListingDeletionModal.jsx';
@@ -64,8 +65,6 @@ const ListingsGrid = () => {
const [jobNameFilter, setJobNameFilter] = useState(null); const [jobNameFilter, setJobNameFilter] = useState(null);
const [activityFilter, setActivityFilter] = useState(null); const [activityFilter, setActivityFilter] = useState(null);
const [providerFilter, setProviderFilter] = useState(null); const [providerFilter, setProviderFilter] = useState(null);
const [showFilterBar, setShowFilterBar] = useState(false);
const [deleteModalVisible, setDeleteModalVisible] = useState(false); const [deleteModalVisible, setDeleteModalVisible] = useState(false);
const [listingToDelete, setListingToDelete] = useState(null); const [listingToDelete, setListingToDelete] = useState(null);
@@ -129,107 +128,84 @@ const ListingsGrid = () => {
return ( return (
<div className="listingsGrid"> <div className="listingsGrid">
<div className="listingsGrid__searchbar"> <div className="listingsGrid__topbar">
<Input prefix={<IconSearch />} showClear placeholder="Search" onChange={handleFilterChange} /> <Input
<Popover content="Filter / Sort Results" style={{ color: 'white', padding: '.5rem' }}> className="listingsGrid__topbar__search"
<div> prefix={<IconSearch />}
<Button showClear
icon={<IconFilter />} placeholder="Search"
onClick={() => { onChange={handleFilterChange}
setShowFilterBar(!showFilterBar); />
}}
/> <RadioGroup
</div> type="button"
</Popover> buttonSize="middle"
value={activityFilter === null ? 'all' : String(activityFilter)}
onChange={(e) => {
const v = e.target.value;
setActivityFilter(v === 'all' ? null : v === 'true');
}}
>
<Radio value="all">All</Radio>
<Radio value="true">Active</Radio>
<Radio value="false">Inactive</Radio>
</RadioGroup>
<RadioGroup
type="button"
buttonSize="middle"
value={watchListFilter === null ? 'all' : String(watchListFilter)}
onChange={(e) => {
const v = e.target.value;
setWatchListFilter(v === 'all' ? null : v === 'true');
}}
>
<Radio value="all">All</Radio>
<Radio value="true">Watched</Radio>
<Radio value="false">Unwatched</Radio>
</RadioGroup>
<Select
placeholder="Provider"
showClear
onChange={(val) => setProviderFilter(val)}
value={providerFilter}
style={{ width: 130 }}
>
{providers?.map((p) => (
<Select.Option key={p.id} value={p.id}>
{p.name}
</Select.Option>
))}
</Select>
<Select
placeholder="Job"
showClear
onChange={(val) => setJobNameFilter(val)}
value={jobNameFilter}
style={{ width: 130 }}
>
{jobs?.map((j) => (
<Select.Option key={j.id} value={j.id}>
{j.name}
</Select.Option>
))}
</Select>
<Select prefix="Sort by" style={{ width: 185 }} value={sortField} onChange={(val) => setSortField(val)}>
<Select.Option value="job_name">Job Name</Select.Option>
<Select.Option value="created_at">Listing Date</Select.Option>
<Select.Option value="price">Price</Select.Option>
<Select.Option value="provider">Provider</Select.Option>
</Select>
<Button
icon={sortDir === 'asc' ? <IconArrowUp /> : <IconArrowDown />}
onClick={() => setSortDir((d) => (d === 'asc' ? 'desc' : 'asc'))}
title={sortDir === 'asc' ? 'Ascending' : 'Descending'}
/>
</div> </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' }}>
<Select
placeholder="Status"
showClear
onChange={(val) => setActivityFilter(val)}
value={activityFilter}
>
<Select.Option value={true}>Active</Select.Option>
<Select.Option value={false}>Not Active</Select.Option>
</Select>
<Select
placeholder="Watchlist"
showClear
onChange={(val) => setWatchListFilter(val)}
value={watchListFilter}
>
<Select.Option value={true}>Watched</Select.Option>
<Select.Option value={false}>Not Watched</Select.Option>
</Select>
<Select
placeholder="Provider"
showClear
onChange={(val) => setProviderFilter(val)}
value={providerFilter}
>
{providers?.map((p) => (
<Select.Option key={p.id} value={p.id}>
{p.name}
</Select.Option>
))}
</Select>
<Select
placeholder="Job Name"
showClear
onChange={(val) => setJobNameFilter(val)}
value={jobNameFilter}
>
{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>Sort by:</Text>
</div>
<div style={{ display: 'flex', gap: '.3rem' }}>
<Select
placeholder="Sort By"
style={{ width: 140 }}
value={sortField}
onChange={(val) => setSortField(val)}
>
<Select.Option value="job_name">Job Name</Select.Option>
<Select.Option value="created_at">Listing Date</Select.Option>
<Select.Option value="price">Price</Select.Option>
<Select.Option value="provider">Provider</Select.Option>
</Select>
<Select
placeholder="Direction"
style={{ width: 120 }}
value={sortDir}
onChange={(val) => setSortDir(val)}
>
<Select.Option value="asc">Ascending</Select.Option>
<Select.Option value="desc">Descending</Select.Option>
</Select>
</div>
</div>
</Space>
</div>
)}
{(listingsData?.result || []).length === 0 && ( {(listingsData?.result || []).length === 0 && (
<Empty <Empty
@@ -240,7 +216,7 @@ const ListingsGrid = () => {
)} )}
<Row gutter={[16, 16]}> <Row gutter={[16, 16]}>
{(listingsData?.result || []).map((item) => ( {(listingsData?.result || []).map((item) => (
<Col key={item.id} xs={24} sm={12} md={8} lg={6} xl={4} xxl={6}> <Col key={item.id} xs={24} sm={12} md={12} lg={8} xl={8} xxl={6}>
<Card <Card
className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`} className={`listingsGrid__card ${!item.is_active ? 'listingsGrid__card--inactive' : ''}`}
style={{ cursor: 'pointer' }} style={{ cursor: 'pointer' }}
@@ -280,10 +256,11 @@ const ListingsGrid = () => {
<Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title"> <Text strong ellipsis={{ showTooltip: true }} className="listingsGrid__title">
{cap(item.title)} {cap(item.title)}
</Text> </Text>
<Space vertical align="start" spacing={2} style={{ width: '100%', marginTop: 8 }}> <div className="listingsGrid__price">
<Text type="secondary" icon={<IconCart />} size="small"> <IconCart size="small" />
{item.price} {item.price}
</Text> </div>
<div className="listingsGrid__meta">
<Text <Text
type="secondary" type="secondary"
icon={<IconMapPin />} icon={<IconMapPin />}
@@ -305,18 +282,17 @@ const ListingsGrid = () => {
</Text> </Text>
) : ( ) : (
<Text type="tertiary" size="small" icon={<IconActivity />}> <Text type="tertiary" size="small" icon={<IconActivity />}>
Distance cannot be calculated, provide an address Distance cannot be calculated
</Text> </Text>
)} )}
</Space> </div>
<Divider margin=".6rem" /> <Divider margin=".6rem" />
<div style={{ display: 'flex', justifyContent: 'space-between' }}> <div className="listingsGrid__actions">
<div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}> <div className="listingsGrid__linkButton" onClick={(e) => e.stopPropagation()}>
<a href={item.link} target="_blank" rel="noopener noreferrer"> <a href={item.link} target="_blank" rel="noopener noreferrer">
<IconLink /> <IconLink />
</a> </a>
</div> </div>
<Button <Button
type="secondary" type="secondary"
size="small" size="small"
@@ -324,7 +300,6 @@ const ListingsGrid = () => {
onClick={() => navigate(`/listings/listing/${item.id}`)} onClick={() => navigate(`/listings/listing/${item.id}`)}
icon={<IconEyeOpened />} icon={<IconEyeOpened />}
/> />
<Button <Button
title="Remove" title="Remove"
type="danger" type="danger"

View File

@@ -1,3 +1,5 @@
@import '../../cards/DashboardCardColors.less';
.listingsGrid { .listingsGrid {
&__imageContainer { &__imageContainer {
position: relative; position: relative;
@@ -5,12 +7,34 @@
overflow: hidden; overflow: hidden;
} }
&__searchbar { &__topbar {
display: flex; display: flex;
gap: .5rem; flex-wrap: wrap;
align-items: center; align-items: center;
justify-content: space-between; gap: 8px;
margin-bottom: 1rem; margin-bottom: 16px;
.listingsGrid__topbar__search {
flex: 1;
min-width: 200px;
}
@media (max-width: 768px) {
.listingsGrid__topbar__search {
width: 100%;
flex: unset;
}
.semi-radio-group {
flex: 1;
}
.semi-select {
flex: 1;
min-width: 100px;
width: auto !important;
}
}
} }
&__watchButton { &__watchButton {
@@ -93,17 +117,27 @@
justify-content: center; justify-content: center;
} }
&__toolbar { &__price {
&__card { font-size: 18px;
border-radius: var(--semi-border-radius-medium); font-weight: 700;
display: flex; color: @color-green-text;
flex-direction: column; display: flex;
gap: .3rem; align-items: center;
background: rgba(36, 36, 36, 0.9); gap: 5px;
backdrop-filter: blur(8px); margin: 8px 0 6px;
padding: 0.5rem; }
border: 1px solid var(--semi-color-border);
} &__meta {
display: flex;
flex-direction: column;
gap: 3px;
width: 100%;
}
&__actions {
display: flex;
justify-content: space-between;
align-items: center;
} }
&__setupButton { &__setupButton {

View File

@@ -42,24 +42,21 @@ export default function Navigation({ isAdmin }) {
]; ];
if (isAdmin) { if (isAdmin) {
const settingsItems = [
{ itemKey: '/users', text: 'User Management' },
{ itemKey: '/userSettings', text: 'User Specific Settings' },
{ itemKey: '/generalSettings', text: 'General Settings' },
];
items.push({ items.push({
itemKey: 'settings', itemKey: 'settings',
text: 'Settings', text: 'Settings',
icon: <IconSetting />, icon: <IconSetting />,
items: settingsItems, items: [
{ itemKey: '/users', text: 'User Management' },
{ itemKey: '/generalSettings', text: 'Settings' },
],
}); });
} else { } else {
items.push({ items.push({
itemKey: 'settings', itemKey: 'settings',
text: 'Settings', text: 'Settings',
icon: <IconSetting />, icon: <IconSetting />,
items: [{ itemKey: '/userSettings', text: 'User Specific Settings' }], items: [{ itemKey: '/generalSettings', text: 'Settings' }],
}); });
} }

View File

@@ -3,5 +3,5 @@
border-radius: .9rem !important; border-radius: .9rem !important;
color: rgba(var(--semi-grey-8), 1); color: rgba(var(--semi-grey-8), 1);
background: rgb(53, 54, 60); background: rgb(53, 54, 60);
margin: 2rem; margin: 0 0 1rem 0;
} }

View File

@@ -4,7 +4,7 @@
*/ */
import React from 'react'; 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 { import {
IconTerminal, IconTerminal,
IconStar, IconStar,
@@ -22,7 +22,6 @@ import KpiCard from '../../components/cards/KpiCard.jsx';
import PieChartCard from '../../components/cards/PieChartCard.jsx'; import PieChartCard from '../../components/cards/PieChartCard.jsx';
import './Dashboard.less'; import './Dashboard.less';
import { SegmentPart } from '../../components/segment/SegmentPart.jsx';
import { xhrPost } from '../../services/xhr.js'; import { xhrPost } from '../../services/xhr.js';
import { format } from '../../services/time/timeService.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 kpis = dashboard?.kpis || { totalJobs: 0, totalListings: 0, providersUsed: 0 };
const pieData = dashboard?.pie || []; const pieData = dashboard?.pie || [];
const { Text } = Typography;
return ( return (
<div className="dashboard"> <div className="dashboard">
<Text className="dashboard__section-label">General</Text>
<Row gutter={[16, 16]} className="dashboard__row"> <Row gutter={[16, 16]} className="dashboard__row">
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}> <Col xs={24} sm={12} md={12} lg={6} xl={6}>
<SegmentPart name="General" Icon={IconTerminal}> <KpiCard
<Row gutter={[16, 16]} className="dashboard__row"> title="Search Interval"
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}> value={`${dashboard?.general?.interval} min`}
<KpiCard icon={<IconClock />}
title="Search Interval" description="Time interval for job execution"
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> </Col>
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}> <Col xs={24} sm={12} md={12} lg={6} xl={6}>
<SegmentPart name="Overview" Icon={IconStar}> <KpiCard
<Row gutter={[16, 16]} className="dashboard__row"> title="Last Search"
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}> valueFontSize="14px"
<KpiCard value={
title="Jobs" dashboard?.general?.lastRun == null || dashboard?.general?.lastRun === 0
color="blue" ? '---'
value={!kpis.totalJobs ? '---' : kpis.totalJobs} : format(dashboard?.general?.lastRun)
icon={<IconTerminal />} }
description="Total number of jobs" icon={<IconDoubleChevronLeft />}
/> description="Last execution timestamp"
</Col> />
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}> </Col>
<KpiCard <Col xs={24} sm={12} md={12} lg={6} xl={6}>
title="Listings" <KpiCard
color="orange" title="Next Search"
value={!kpis.totalListings ? '---' : kpis.totalListings} value={
icon={<IconStarStroked />} dashboard?.general?.nextRun == null || dashboard?.general?.nextRun === 0
description="Total listings found" ? '---'
/> : format(dashboard?.general?.nextRun)
</Col> }
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}> valueFontSize="14px"
<KpiCard icon={<IconDoubleChevronRight />}
title="Active Listings" description="Next execution timestamp"
color="green" />
value={!kpis.numberOfActiveListings ? '---' : kpis.numberOfActiveListings} </Col>
icon={<IconStar />} <Col xs={24} sm={12} md={12} lg={6} xl={6}>
description="Total active listings" <KpiCard title="Search Now" icon={<IconSearch />} description="Run a search now">
/> <Button
</Col> size="small"
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}> style={{ marginTop: '.2rem' }}
<KpiCard icon={<IconPlayCircle />}
title="Avg. Price" aria-label="Start now"
color="purple" onClick={async () => {
value={`${ try {
!kpis.avgPriceOfListings await xhrPost('/api/jobs/startAll', null);
? '---' Toast.success('Successfully triggered Fredy search.');
: new Intl.NumberFormat('de-DE', { } catch {
style: 'currency', Toast.error('Failed to trigger search');
currency: 'EUR', }
}).format(kpis.avgPriceOfListings) }}
}`} >
icon={<IconNoteMoney />} Search now
description="Avg. Price of listings" </Button>
/> </KpiCard>
</Col>
</Row>
</SegmentPart>
</Col> </Col>
</Row> </Row>
<SegmentPart <Text className="dashboard__section-label">Overview</Text>
name="Provider Insights" <Row gutter={[16, 16]} className="dashboard__row">
Icon={IconStar} <Col xs={24} sm={12} md={12} lg={6} xl={6}>
helpText="Percentage of found listings over all providers" <KpiCard
className="dashboard__provider-insights" 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} /> <PieChartCard data={pieData} />
</SegmentPart> </div>
</div> </div>
); );
} }

View File

@@ -3,31 +3,32 @@
flex-direction: column; flex-direction: column;
flex: 1; flex: 1;
&__row { &__section-label {
margin-bottom: 24px; display: block;
flex-wrap: wrap; font-size: 11px !important;
font-weight: 600 !important;
.semi-col { text-transform: uppercase;
margin-bottom: 0; // Handled by Row gutter 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; flex: 1;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
margin: 0 !important; justify-content: center;
.semi-card-body {
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
max-height: 300px;
> * {
flex: 1;
}
}
} }
} }

View File

@@ -3,31 +3,38 @@
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause * 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 { 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 { SegmentPart } from '../../components/segment/SegmentPart';
import { Banner, Toast } from '@douyinfe/semi-ui-19';
import { import {
downloadBackup as downloadBackupZip, downloadBackup as downloadBackupZip,
precheckRestore as clientPrecheckRestore, precheckRestore as clientPrecheckRestore,
restore as clientRestore, restore as clientRestore,
} from '../../services/backupRestoreClient'; } from '../../services/backupRestoreClient';
import { import { IconSave, IconRefresh, IconSignal, IconHome, IconFolder } from '@douyinfe/semi-icons';
IconSave, import debounce from 'lodash/debounce';
IconCalendar,
IconRefresh,
IconSignal,
IconLineChartStroked,
IconSearch,
IconFolder,
} from '@douyinfe/semi-icons';
import './GeneralSettings.less'; import './GeneralSettings.less';
const { Text } = Typography;
function formatFromTimestamp(ts) { function formatFromTimestamp(ts) {
const date = new Date(ts); const date = new Date(ts);
return `${date.getHours()}:${date.getMinutes() > 9 ? date.getMinutes() : '0' + date.getMinutes()}`; 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 [restoreBusy, setRestoreBusy] = React.useState(false);
const [selectedRestoreFile, setSelectedRestoreFile] = React.useState(null); 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(() => { React.useEffect(() => {
async function init() { async function init() {
await actions.generalSettings.getGeneralSettings(); await actions.generalSettings.getGeneralSettings();
@@ -86,6 +101,11 @@ const GeneralSettings = function GeneralSettings() {
init(); init();
}, [settings]); }, [settings]);
useEffect(() => {
setAddress(homeAddress?.address || '');
setCoords(homeAddress?.coords || null);
}, [homeAddress]);
const nullOrEmpty = (val) => val == null || val.length === 0; const nullOrEmpty = (val) => val == null || val.length === 0;
const handleStore = async () => { const handleStore = async () => {
@@ -177,7 +197,6 @@ const GeneralSettings = function GeneralSettings() {
if (!file) return; if (!file) return;
setSelectedRestoreFile(file); setSelectedRestoreFile(file);
await precheckRestore(file); await precheckRestore(file);
// reset the input to allow same file re-select
ev.target.value = ''; ev.target.value = '';
}, },
[precheckRestore], [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 ( return (
<div> <div className="generalSettings">
{!loading && ( {!loading && (
<React.Fragment> <>
<div> <Tabs type="line">
<SegmentPart <TabPane
name="Interval" tab={
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." <span>
Icon={IconRefresh} <IconSignal size="small" style={{ marginRight: 6 }} />
System
</span>
}
itemKey="system"
> >
<InputNumber <div className="generalSettings__tab-content">
min={5} <SegmentPart name="Port" helpText="The port on which Fredy is running.">
max={1440} <InputNumber
placeholder="Interval in minutes" min={0}
value={interval} max={99999}
formatter={(value) => `${value}`.replace(/\D/g, '')} placeholder="Port"
onChange={(value) => setInterval(value)} value={port}
suffix={'minutes'} formatter={(value) => `${value}`.replace(/\D/g, '')}
/> onChange={(value) => setPort(value)}
</SegmentPart> style={{ maxWidth: 160 }}
<Divider margin="1rem" /> />
<SegmentPart </SegmentPart>
name="Backup & Restore"
helpText="Download a zipped backup of your database or restore it from a backup zip." <SegmentPart
Icon={IconSave} name="SQLite Database Path"
> helpText="The directory where Fredy stores its SQLite database files."
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}> >
<Button theme="solid" icon={<IconSave />} onClick={handleDownloadBackup}> <Banner
Download backup fullMode={false}
</Button> type="warning"
<input closeIcon={null}
type="file" style={{ marginBottom: '12px' }}
accept=".zip,application/zip" description="Changing this path may result in data loss. Restart Fredy immediately after saving."
ref={fileInputRef} />
style={{ display: 'none' }} <Input
onChange={handleSelectRestoreFile} type="text"
/> placeholder="Database folder path"
<Button onClick={handleOpenFilePicker} theme="light" icon={<IconFolder />}> value={sqlitePath}
Restore from zip onChange={(value) => setSqlitePath(value)}
</Button> />
</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> </div>
</SegmentPart> </TabPane>
<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>
}
/>
<Input <TabPane
type="text" tab={
placeholder="Select folder" <span>
value={sqlitePath} <IconRefresh size="small" style={{ marginRight: 6 }} />
onChange={(value) => { Execution
setSqlitePath(value); </span>
}} }
/> itemKey="execution"
</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}
> >
<div className="generalSettings__timePickerContainer"> <div className="generalSettings__tab-content">
<TimePicker <SegmentPart
format={'HH:mm'} name="Search Interval"
insetLabel="From" helpText="Interval in minutes for running queries against configured services. Do not go below 5 minutes to avoid being detected as a bot."
value={formatFromTBackend(workingHourFrom)} >
placeholder="" <InputNumber
onChange={(val) => { min={5}
setWorkingHourFrom(val == null ? null : formatFromTimestamp(val)); max={1440}
}} placeholder="Interval in minutes"
/> value={interval}
<TimePicker formatter={(value) => `${value}`.replace(/\D/g, '')}
format={'HH:mm'} onChange={(value) => setInterval(value)}
insetLabel="Until" suffix={'minutes'}
value={formatFromTBackend(workingHourTo)} style={{ maxWidth: 200 }}
placeholder="" />
onChange={(val) => { </SegmentPart>
setWorkingHourTo(val == null ? null : formatFromTimestamp(val));
}} <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> </div>
</SegmentPart> </TabPane>
<Divider margin="1rem" />
<SegmentPart name="Analytics" helpText="Insights into the usage of Fredy." Icon={IconLineChartStroked}> <TabPane
<Banner tab={
fullMode={false} <span>
type="info" <IconFolder size="small" style={{ marginRight: 6 }} />
closeIcon={null} Backup & Restore
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Explanation</div>} </span>
style={{ marginBottom: '1rem' }} }
description={ itemKey="backup"
<div> >
Analytics are disabled by default. If you choose to enable them, we will begin tracking the <div className="generalSettings__tab-content">
following: <SegmentPart
<br /> name="Backup & Restore"
<ul> helpText="Download a zipped backup of your database or restore from a backup zip."
<li>Name of active provider (e.g. Immoscout)</li> >
<li>Name of active adapter (e.g. Console)</li> <div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center', flexWrap: 'wrap' }}>
<li>language</li> <Button theme="solid" icon={<IconSave />} onClick={handleDownloadBackup}>
<li>os</li> Download Backup
<li>node version</li> </Button>
<li>arch</li> <input
</ul> type="file"
The data is sent anonymously and helps me understand which providers or adapters are being used the accept=".zip,application/zip"
most. In the end it helps me to improve fredy. ref={fileInputRef}
style={{ display: 'none' }}
onChange={handleSelectRestoreFile}
/>
<Button onClick={handleOpenFilePicker} theme="light" icon={<IconFolder />}>
Restore from Zip
</Button>
</div> </div>
} </SegmentPart>
/> </div>
</TabPane>
<Checkbox checked={analyticsEnabled} onChange={(e) => setAnalyticsEnabled(e.target.checked)}> <TabPane
{' '} tab={
Enabled <span>
</Checkbox> <IconHome size="small" style={{ marginRight: 6 }} />
</SegmentPart> 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="ImmoScout Details"
<SegmentPart name="Demo Mode" helpText="If enabled, Fredy runs in demo mode." Icon={IconSearch}> helpText="Fetch additional details (description, attributes, agent info) for ImmoScout listings. Makes an extra API call per listing."
<Banner >
fullMode={false} <Banner
type="info" type="warning"
closeIcon={null} description="Enabling this significantly increases API requests to ImmoScout, raising the chance of rate limiting or blocking. Use at your own risk."
title={<div style={{ fontWeight: 600, fontSize: '14px', lineHeight: '20px' }}>Explanation</div>} closeIcon={null}
style={{ marginBottom: '1rem' }} style={{ marginBottom: 12 }}
description={ />
<div> <div style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
In demo mode, Fredy will not (really) search for any real estates. Fredy is in a lockdown mode. Also <Switch
all database files will be set back to the default values at midnight. 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> </div>
} </SegmentPart>
/>
<Checkbox checked={demoMode} onChange={(e) => setDemoMode(e.target.checked)}> <div className="generalSettings__save-row">
{' '} <Button
Enabled icon={<IconSave />}
</Checkbox> theme="solid"
</SegmentPart> type="primary"
onClick={handleSaveUserSettings}
<Divider margin="1rem" /> loading={saving}
<Button type="primary" theme="solid" onClick={handleStore} icon={<IconSave />}> >
Save Save
</Button> </Button>
</div> </div>
</React.Fragment> </div>
</TabPane>
</Tabs>
</>
)} )}
{restoreModalVisible && ( {restoreModalVisible && (
<Modal <Modal
title="Restore database" title="Restore database"

View File

@@ -1,12 +1,17 @@
.generalSettings { .generalSettings {
&__tab-content {
padding: 20px 0;
max-width: 860px;
}
&__timePickerContainer { &__timePickerContainer {
display: flex; display: flex;
align-items: baseline; align-items: baseline;
gap: 1rem; gap: 1rem;
flex-wrap: wrap;
} }
&__help { &__save-row {
font-size: 11px; margin-top: 1.5rem;
margin-left: 1rem;
} }
} }

View File

@@ -9,12 +9,13 @@ import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css'; import 'maplibre-gl/dist/maplibre-gl.css';
import { useSelector, useActions } from '../../services/state/store.js'; import { useSelector, useActions } from '../../services/state/store.js';
import { distanceMeters, generateCircleCoords, getBoundsFromCenter, getBoundsFromCoords } from './mapUtils.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 { Select, Typography, Switch, Banner, Toast } from '@douyinfe/semi-ui-19';
import { IconFilter, IconLink } from '@douyinfe/semi-icons'; import { IconLink } from '@douyinfe/semi-icons';
import { IconDelete, IconEyeOpened } from '@douyinfe/semi-icons'; import { IconDelete, IconEyeOpened } from '@douyinfe/semi-icons';
import no_image from '../../assets/no_image.jpg'; 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 'react-range-slider-input/dist/style.css';
import './Map.less'; import './Map.less';
import { xhrDelete } from '../../services/xhr.js'; import { xhrDelete } from '../../services/xhr.js';
@@ -39,7 +40,6 @@ export default function MapView() {
const jobs = useSelector((state) => state.jobsData.jobs); const jobs = useSelector((state) => state.jobsData.jobs);
const [jobId, setJobId] = useState(null); const [jobId, setJobId] = useState(null);
const [priceRange, setPriceRange] = useState([0, 0]); const [priceRange, setPriceRange] = useState([0, 0]);
const [showFilterBar, setShowFilterBar] = useState(false);
const [distanceFilter, setDistanceFilter] = useState(0); const [distanceFilter, setDistanceFilter] = useState(0);
const [deleteModalVisible, setDeleteModalVisible] = useState(false); const [deleteModalVisible, setDeleteModalVisible] = useState(false);
@@ -92,10 +92,8 @@ export default function MapView() {
}; };
}, [navigate]); }, [navigate]);
// Get map instance reference after MapComponent renders
useEffect(() => { useEffect(() => {
if (mapContainer.current && !map.current) { if (mapContainer.current && !map.current) {
// Wait for MapComponent to initialize the map
const checkMapReady = () => { const checkMapReady = () => {
if (mapContainer.current?.map) { if (mapContainer.current?.map) {
map.current = mapContainer.current.map; map.current = mapContainer.current.map;
@@ -132,8 +130,6 @@ export default function MapView() {
if (!map.current) return; if (!map.current) return;
if (homeAddress?.coords) { 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) { if (distanceFilter > 0) {
const bounds = getBoundsFromCenter([homeAddress.coords.lng, homeAddress.coords.lat], distanceFilter); 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); const popup = new maplibregl.Popup({ offset: 25 }).setHTML(popupContent);
let color = '#3FB1CE'; // Default blue-ish let color = '#3FB1CE';
if (distanceFilter > 0 && homeAddress?.coords) { if (distanceFilter > 0 && homeAddress?.coords) {
const dist = distanceMeters( const dist = distanceMeters(
homeAddress.coords.lat, homeAddress.coords.lat,
@@ -315,114 +311,17 @@ export default function MapView() {
return ( return (
<div className="map-view-container"> <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 && ( {!homeAddress && (
<Banner <Banner
fullMode={true} fullMode={true}
type="warning" type="warning"
bordered bordered
closeIcon={null} closeIcon={null}
style={{ marginBottom: '8px' }}
description={ description={
<span> <span>
You have not set your home address yet. Please do so in the <Link to="/userSettings">user settings</Link>{' '} No home address set. Configure it in <Link to="/userSettings">user settings</Link> to use the distance
to use the distance filter. filter.
</span> </span>
} }
/> />
@@ -433,10 +332,103 @@ export default function MapView() {
type="info" type="info"
bordered bordered
closeIcon={null} 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 <ListingDeletionModal
visible={deleteModalVisible} visible={deleteModalVisible}
onConfirm={confirmListingDeletion} onConfirm={confirmListingDeletion}

View File

@@ -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 * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/ */
@@ -9,18 +9,48 @@
height: 100%; height: 100%;
padding: 0; padding: 0;
box-sizing: border-box; box-sizing: border-box;
}
.map-filter-bar { &__map-wrapper {
margin-bottom: 1rem; 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 { .map-container {
flex-grow: 1;
width: 100%; width: 100%;
height: 100%;
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
border: 1px solid var(--semi-color-border); border: 1px solid #262a3a;
} }
.map-popup-content { .map-popup-content {
@@ -126,7 +156,7 @@
} }
} }
/* Override MapLibre default popup styles to match application theme */ /* Override MapLibre default popup styles */
.maplibregl-popup-content { .maplibregl-popup-content {
background-color: var(--semi-color-bg-1) !important; background-color: var(--semi-color-bg-1) !important;
color: var(--semi-color-text-0) !important; color: var(--semi-color-text-0) !important;
@@ -140,21 +170,26 @@
} }
.map { .map {
&__rangesliderLabels{ &__rangesliderLabels {
color: white; color: #94a3b8;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
margin-bottom: .3rem; margin-bottom: .3rem;
font-size: .7rem; font-size: .7rem;
} }
} }
.range-slider .range-slider__thumb { .range-slider .range-slider__thumb {
position: absolute; position: absolute;
z-index: 3; z-index: 3;
top: 50%; top: 50%;
width: 16px; width: 14px;
height: 16px; height: 14px;
transform: translate(-50%, -50%); transform: translate(-50%, -50%);
border-radius: 50%; border-radius: 50%;
background: #2196f3; background: #0ab5b3;
}
.range-slider .range-slider__range {
background: #0ab5b3;
} }

View File

@@ -1082,19 +1082,12 @@
debug "^4.3.1" debug "^4.3.1"
minimatch "^10.2.4" minimatch "^10.2.4"
"@eslint/config-helpers@^0.5.2": "@eslint/config-helpers@^0.5.3":
version "0.5.2" version "0.5.3"
resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.5.2.tgz#314c7b03d02a371ad8c0a7f6821d5a8a8437ba9d" resolved "https://registry.yarnpkg.com/@eslint/config-helpers/-/config-helpers-0.5.3.tgz#721fe6bbb90d74b0c80d6ff2428e5bbcb002becb"
integrity sha512-a5MxrdDXEvqnIq+LisyCX6tQMPF/dSJpCfBgBauY+pNZ28yCtSsTvyTYrMhaI+LK26bVyCJfJkT0u8KIj2i1dQ== integrity sha512-lzGN0onllOZCGroKJmRwY6QcEHxbjBw1gwB8SgRSqK8YbbtEXMvKynsXc3553ckIEBxsbMBU7oOZXKIPGZNeZw==
dependencies: dependencies:
"@eslint/core" "^1.1.0" "@eslint/core" "^1.1.1"
"@eslint/core@^1.1.0":
version "1.1.0"
resolved "https://registry.yarnpkg.com/@eslint/core/-/core-1.1.0.tgz#51f5cd970e216fbdae6721ac84491f57f965836d"
integrity sha512-/nr9K9wkr3P1EzFTdFdMoLuo1PmIxjmwvPozwoSodjNBdefGujXQUF93u1DDZpEaTuDvMsIQddsd35BwtrW9Xw==
dependencies:
"@types/json-schema" "^7.0.15"
"@eslint/core@^1.1.1": "@eslint/core@^1.1.1":
version "1.1.1" version "1.1.1"
@@ -1276,7 +1269,7 @@
resolved "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz" resolved "https://registry.npmjs.org/@maplibre/geojson-vt/-/geojson-vt-5.0.4.tgz"
integrity sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ== integrity sha512-KGg9sma45S+stfH9vPCJk1J0lSDLWZgCT9Y8u8qWZJyjFlP8MNP1WGTxIMYJZjDvVT3PDn05kN1C95Sut1HpgQ==
"@maplibre/geojson-vt@^6.0.3": "@maplibre/geojson-vt@^6.0.4":
version "6.0.4" version "6.0.4"
resolved "https://registry.yarnpkg.com/@maplibre/geojson-vt/-/geojson-vt-6.0.4.tgz#f028fa633594c067b4c24030c1c282c0dd6cc835" resolved "https://registry.yarnpkg.com/@maplibre/geojson-vt/-/geojson-vt-6.0.4.tgz#f028fa633594c067b4c24030c1c282c0dd6cc835"
integrity sha512-HYv3POhMRCdhP3UPPATM/hfcy6/WuVIf5FKboH8u/ZuFMTnAIcSVlq5nfOqroLokd925w2QtE7YwquFOIacwVQ== integrity sha512-HYv3POhMRCdhP3UPPATM/hfcy6/WuVIf5FKboH8u/ZuFMTnAIcSVlq5nfOqroLokd925w2QtE7YwquFOIacwVQ==
@@ -1296,10 +1289,10 @@
rw "^1.3.3" rw "^1.3.3"
tinyqueue "^3.0.0" tinyqueue "^3.0.0"
"@maplibre/mlt@^1.1.7": "@maplibre/mlt@^1.1.8":
version "1.1.7" version "1.1.8"
resolved "https://registry.yarnpkg.com/@maplibre/mlt/-/mlt-1.1.7.tgz#cb8d6ede486f5e48a33dd1f373fa5d908ce8062f" resolved "https://registry.yarnpkg.com/@maplibre/mlt/-/mlt-1.1.8.tgz#ad1f7169197e5c64eace4f61c168dcd202076e03"
integrity sha512-HZSsXrgn2V6T3o0qklMwKERfKaAxjO8shmiFnVygCtXTg4SPKWVX+U99RkvxUfCsjYBEcT4ltor8lSlBSCca7Q== integrity sha512-8vtfYGidr1rNkv5IwIoU2lfe3Oy+Wa8HluzQYcQi9cveU9K3pweAal/poQj4GJ0K/EW4bTQp2wVAs09g2yDRZg==
dependencies: dependencies:
"@mapbox/point-geometry" "^1.1.0" "@mapbox/point-geometry" "^1.1.0"
@@ -3502,15 +3495,15 @@ eslint-visitor-keys@^5.0.1:
resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be" resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz#9e3c9489697824d2d4ce3a8ad12628f91e9f59be"
integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA== integrity sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==
eslint@10.0.3: eslint@10.1.0:
version "10.0.3" version "10.1.0"
resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.0.3.tgz#360a7de7f2706eb8a32caa17ca983f0089efe694" resolved "https://registry.yarnpkg.com/eslint/-/eslint-10.1.0.tgz#9ca98e654e642ab2e1af6d1e9d8613857ac341b4"
integrity sha512-COV33RzXZkqhG9P2rZCFl9ZmJ7WL+gQSCRzE7RhkbclbQPtLAWReL7ysA0Sh4c8Im2U9ynybdR56PV0XcKvqaQ== integrity sha512-S9jlY/ELKEUwwQnqWDO+f+m6sercqOPSqXM5Go94l7DOmxHVDgmSFGWEzeE/gwgTAr0W103BWt0QLe/7mabIvA==
dependencies: dependencies:
"@eslint-community/eslint-utils" "^4.8.0" "@eslint-community/eslint-utils" "^4.8.0"
"@eslint-community/regexpp" "^4.12.2" "@eslint-community/regexpp" "^4.12.2"
"@eslint/config-array" "^0.23.3" "@eslint/config-array" "^0.23.3"
"@eslint/config-helpers" "^0.5.2" "@eslint/config-helpers" "^0.5.3"
"@eslint/core" "^1.1.1" "@eslint/core" "^1.1.1"
"@eslint/plugin-kit" "^0.6.1" "@eslint/plugin-kit" "^0.6.1"
"@humanfs/node" "^0.16.6" "@humanfs/node" "^0.16.6"
@@ -3523,7 +3516,7 @@ eslint@10.0.3:
escape-string-regexp "^4.0.0" escape-string-regexp "^4.0.0"
eslint-scope "^9.1.2" eslint-scope "^9.1.2"
eslint-visitor-keys "^5.0.1" eslint-visitor-keys "^5.0.1"
espree "^11.1.1" espree "^11.2.0"
esquery "^1.7.0" esquery "^1.7.0"
esutils "^2.0.2" esutils "^2.0.2"
fast-deep-equal "^3.1.3" fast-deep-equal "^3.1.3"
@@ -3538,7 +3531,7 @@ eslint@10.0.3:
natural-compare "^1.4.0" natural-compare "^1.4.0"
optionator "^0.9.3" optionator "^0.9.3"
espree@^11.1.1: espree@^11.2.0:
version "11.2.0" version "11.2.0"
resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5" resolved "https://registry.yarnpkg.com/espree/-/espree-11.2.0.tgz#01d5e47dc332aaba3059008362454a8cc34ccaa5"
integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw== integrity sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==
@@ -4988,10 +4981,10 @@ make-dir@^2.1.0:
pify "^4.0.1" pify "^4.0.1"
semver "^5.6.0" semver "^5.6.0"
maplibre-gl@^5.20.2: maplibre-gl@^5.21.0:
version "5.20.2" version "5.21.0"
resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-5.20.2.tgz#9dec242f0858f3bc30fd5c44404ed9e23e63adaf" resolved "https://registry.yarnpkg.com/maplibre-gl/-/maplibre-gl-5.21.0.tgz#2d2bc81196d4b922a00c4cc7f90175f53eb7a2d3"
integrity sha512-0UzMWOe+GZmIUmOA99yTI1vRh15YcGnHxADVB2s+JF3etpjj2/MBCqbPEuu4BP9mLsJWJcpHH0Nzr9uuimmbuQ== integrity sha512-n0v4J/Ge0EG8ix/z3TY3ragtJYMqzbtSnj1riOC0OwQbzwp0lUF2maS1ve1z8HhitQCKtZZiZJhb8to36aMMfQ==
dependencies: dependencies:
"@mapbox/jsonlint-lines-primitives" "^2.0.2" "@mapbox/jsonlint-lines-primitives" "^2.0.2"
"@mapbox/point-geometry" "^1.1.0" "@mapbox/point-geometry" "^1.1.0"
@@ -4999,9 +4992,9 @@ maplibre-gl@^5.20.2:
"@mapbox/unitbezier" "^0.0.1" "@mapbox/unitbezier" "^0.0.1"
"@mapbox/vector-tile" "^2.0.4" "@mapbox/vector-tile" "^2.0.4"
"@mapbox/whoots-js" "^3.1.0" "@mapbox/whoots-js" "^3.1.0"
"@maplibre/geojson-vt" "^6.0.3" "@maplibre/geojson-vt" "^6.0.4"
"@maplibre/maplibre-gl-style-spec" "^24.7.0" "@maplibre/maplibre-gl-style-spec" "^24.7.0"
"@maplibre/mlt" "^1.1.7" "@maplibre/mlt" "^1.1.8"
"@maplibre/vt-pbf" "^4.3.0" "@maplibre/vt-pbf" "^4.3.0"
"@types/geojson" "^7946.0.16" "@types/geojson" "^7946.0.16"
earcut "^3.0.2" earcut "^3.0.2"