Improvements 01 28 (#264)

* improving footer

* improve ui

* upgrading dependencies

* adding glow to all boxes on dashboard

* introducing single listing view

* next release version

* improve screenshots and login page
This commit is contained in:
Christian Kellner
2026-01-28 14:27:03 +01:00
committed by GitHub
parent 3117044139
commit 472169693f
29 changed files with 999 additions and 383 deletions

View File

@@ -41,10 +41,10 @@ export default function Dashboard() {
<div className="dashboard">
<Headline text="Dashboard" size={3} />
<Row gutter={16} className="dashboard__row">
<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} className="dashboard__row">
<Row gutter={[16, 16]} className="dashboard__row">
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
<KpiCard
title="Search Interval"
@@ -104,7 +104,7 @@ export default function Dashboard() {
</Col>
<Col span={12} xs={24} sm={24} md={24} lg={24} xl={12}>
<SegmentPart name="Overview" Icon={IconStar}>
<Row gutter={16} className="dashboard__row">
<Row gutter={[16, 16]} className="dashboard__row">
<Col span={12} xs={24} sm={12} md={12} lg={12} xl={12}>
<KpiCard
title="Jobs"
@@ -147,7 +147,7 @@ export default function Dashboard() {
</Row>
<SegmentPart name="Provider Insights" Icon={IconStar} helpText="Percentage of found listings over all providers">
<PieChartCard title="Jobs per Provider" data={pieData} isLoading={false} />
<PieChartCard data={pieData} />
</SegmentPart>
</div>
);

View File

@@ -1,11 +1,10 @@
.dashboard {
&__row {
margin-bottom: 1rem;
/* Ensure grid items wrap to next line on narrow screens */
margin-bottom: 24px;
flex-wrap: wrap;
/* Vertical gap of 1rem between wrapped grid items (no px) */
.semi-col {
margin-bottom: 1rem;
margin-bottom: 0; // Handled by Row gutter
}
}
}

View File

@@ -0,0 +1,383 @@
/*
* Copyright (c) 2026 by Christian Kellner.
* Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause
*/
import React, { useEffect, useRef, useState } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useSelector, useActions } from '../../services/state/store.js';
import {
Typography,
Button,
Space,
Card,
Row,
Col,
Image,
Tag,
Divider,
Descriptions,
Banner,
Spin,
Toast,
} from '@douyinfe/semi-ui-19';
import {
IconArrowLeft,
IconMapPin,
IconCart,
IconClock,
IconBriefcase,
IconActivity,
IconLink,
IconStar,
IconStarStroked,
IconRealSize,
} from '@douyinfe/semi-icons';
import maplibregl from 'maplibre-gl';
import 'maplibre-gl/dist/maplibre-gl.css';
import no_image from '../../assets/no_image.jpg';
import * as timeService from '../../services/time/timeService.js';
import { distanceMeters, getBoundsFromCoords } from './mapUtils.js';
import { xhrPost } from '../../services/xhr.js';
import './ListingDetail.less';
const { Title, Text } = Typography;
const STYLES = {
STANDARD: 'https://tiles.openfreemap.org/styles/bright',
};
export default function ListingDetail() {
const { listingId } = useParams();
const navigate = useNavigate();
const actions = useActions();
const listing = useSelector((state) => state.listingsData.currentListing);
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const mapContainer = useRef(null);
const map = useRef(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
async function fetchListing() {
try {
setLoading(true);
await actions.listingsData.getListing(listingId);
} catch (e) {
console.error('Failed to load listing details:', e);
Toast.error('Failed to load listing details');
navigate('/listings');
} finally {
setLoading(false);
}
}
fetchListing();
}, [listingId]);
const hasGeo =
listing?.latitude != null && listing?.longitude != null && listing?.latitude !== -1 && listing?.longitude !== -1;
useEffect(() => {
if (loading || !listing || !mapContainer.current || !hasGeo) return;
if (map.current) {
map.current.remove();
}
map.current = new maplibregl.Map({
container: mapContainer.current,
style: STYLES.STANDARD,
center: [listing.longitude, listing.latitude],
zoom: 14,
cooperativeGestures: true,
});
map.current.addControl(new maplibregl.NavigationControl(), 'top-right');
new maplibregl.Marker({ color: '#3FB1CE' })
.setLngLat([listing.longitude, listing.latitude])
.setPopup(new maplibregl.Popup({ offset: 25 }).setHTML(`<h4>Listing Location</h4><p>${listing.address}</p>`))
.addTo(map.current);
if (homeAddress?.coords) {
new maplibregl.Marker({ color: 'red' })
.setLngLat([homeAddress.coords.lng, homeAddress.coords.lat])
.setPopup(new maplibregl.Popup({ offset: 25 }).setHTML(`<h4>Home Address</h4><p>${homeAddress.address}</p>`))
.addTo(map.current);
const bounds = getBoundsFromCoords([
[listing.longitude, listing.latitude],
[homeAddress.coords.lng, homeAddress.coords.lat],
]);
map.current.fitBounds(bounds, {
padding: 50,
maxZoom: 15,
});
const drawLine = () => {
if (!map.current || !map.current.isStyleLoaded()) return;
const distance = distanceMeters(
listing.latitude,
listing.longitude,
homeAddress.coords.lat,
homeAddress.coords.lng,
);
const midpoint = [
(listing.longitude + homeAddress.coords.lng) / 2,
(listing.latitude + homeAddress.coords.lat) / 2,
];
if (map.current.getSource('route')) {
map.current.getSource('route').setData({
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [
[listing.longitude, listing.latitude],
[homeAddress.coords.lng, homeAddress.coords.lat],
],
},
},
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: midpoint,
},
properties: {
distance: `${Math.round(distance)} m`,
},
},
],
});
} else {
map.current.addSource('route', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: [
{
type: 'Feature',
geometry: {
type: 'LineString',
coordinates: [
[listing.longitude, listing.latitude],
[homeAddress.coords.lng, homeAddress.coords.lat],
],
},
},
{
type: 'Feature',
geometry: {
type: 'Point',
coordinates: midpoint,
},
properties: {
distance: `${Math.round(distance)} m`,
},
},
],
},
});
map.current.addLayer({
id: 'route',
type: 'line',
source: 'route',
layout: {
'line-join': 'round',
'line-cap': 'round',
},
paint: {
'line-color': '#3FB1CE',
'line-width': 4,
'line-dasharray': [2, 1],
},
filter: ['==', '$type', 'LineString'],
});
map.current.addLayer({
id: 'route-distance',
type: 'symbol',
source: 'route',
layout: {
'text-field': ['get', 'distance'],
'text-size': 14,
'text-offset': [0, -1],
'text-allow-overlap': true,
},
paint: {
'text-color': '#ffffff',
'text-halo-color': '#3FB1CE',
'text-halo-width': 2,
},
filter: ['==', '$type', 'Point'],
});
}
};
if (map.current.isStyleLoaded()) {
drawLine();
} else {
map.current.on('load', drawLine);
}
}
return () => {
if (map.current) {
map.current.remove();
map.current = null;
}
};
}, [listing, loading, homeAddress]);
const handleWatch = async () => {
try {
await xhrPost('/api/listings/watch', { listingId: listing.id });
Toast.success(listing.isWatched === 1 ? 'Removed from Watchlist' : 'Added to Watchlist');
actions.listingsData.getListing(listingId);
} catch (e) {
console.error('Failed to operate Watchlist:', e);
Toast.error('Failed to operate Watchlist');
}
};
if (loading) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', height: '100%' }}>
<Spin size="large" />
</div>
);
}
if (!listing) return null;
const data = [
{
key: 'Job',
value: listing.job_name,
Icon: <IconBriefcase />,
},
{
key: 'Provider',
value: listing.provider.charAt(0).toUpperCase() + listing.provider.slice(1),
Icon: <IconBriefcase />,
},
{ key: 'Price', value: `${listing.price}`, Icon: <IconCart /> },
{
key: 'Size',
value: listing.size ? `${listing.size}` : 'N/A',
Icon: <IconRealSize />,
},
{
key: 'Added',
value: timeService.format(listing.created_at),
Icon: <IconClock />,
},
];
return (
<div className="listing-detail">
<div className="listing-detail__back">
<Button icon={<IconArrowLeft />} onClick={() => navigate(-1)} theme="borderless">
Back
</Button>
</div>
<Card className="listing-detail__card">
<div className="listing-detail__header">
<Space vertical align="start" spacing="tight">
<Title heading={2} className="listing-detail__title">
{listing.title}
</Title>
<Space align="center">
<IconMapPin style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} />
<Text type="secondary">{listing.address || 'No address provided'}</Text>
</Space>
</Space>
<Space wrap className="listing-detail__header-actions">
<Button
icon={
listing.isWatched === 1 ? (
<IconStar style={{ color: 'var(--semi-color-warning)' }} />
) : (
<IconStarStroked />
)
}
onClick={handleWatch}
theme="light"
>
{listing.isWatched === 1 ? 'Watched' : 'Watch'}
</Button>
<Text link={{ href: listing.link }} icon={<IconLink />} underline>
Open listing
</Text>
</Space>
</div>
<Row>
<Col span={24} lg={12}>
<div className="listing-detail__image-container">
<Image src={listing.image_url || no_image} fallback={no_image} preview={true} />
</div>
</Col>
<Col span={24} lg={12}>
<div className="listing-detail__info-section">
<Title heading={4} style={{ marginBottom: '1rem' }}>
Details
</Title>
<Descriptions column={1}>
{data.map((item, index) => (
<Descriptions.Item key={index}>
<Space>
{item.Icon}
{item.value}
</Space>
</Descriptions.Item>
))}
</Descriptions>
<Divider margin="1.5rem" />
<Title heading={4} style={{ marginBottom: '1rem' }}>
Description
</Title>
<Text type="secondary" style={{ whiteSpace: 'pre-wrap' }}>
{listing.description || 'No description available.'}
</Text>
{listing.distance_to_destination && (
<>
<Divider margin="1.5rem" />
<Space align="center">
<IconActivity style={{ fontSize: '18px', color: 'var(--semi-color-primary)' }} />
<Text strong>Distance to home:</Text>
<Tag color="blue">{listing.distance_to_destination} m</Tag>
</Space>
</>
)}
</div>
</Col>
</Row>
</Card>
<div className="listing-detail__map-wrapper">
<Title heading={3}>Location</Title>
{!hasGeo ? (
<Banner
type="warning"
bordered
description="This listing has no valid geocoordinates, so we cannot show it on the map."
style={{ marginTop: '1rem' }}
/>
) : (
<div ref={mapContainer} className="listing-detail__map-container" />
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,110 @@
.listing-detail {
padding-bottom: 2rem;
&__back {
margin-bottom: 1.5rem;
}
&__card {
background-color: rgba(36, 36, 36, 0.9);
backdrop-filter: blur(8px);
border: 1px solid var(--semi-color-border);
margin-bottom: 2rem;
overflow: hidden;
.semi-card-body {
padding: 0;
}
}
&__header {
display: flex;
justify-content: space-between;
align-items: flex-start;
padding: 1.5rem;
gap: 1rem;
border-bottom: 1px solid var(--semi-color-border);
@media (max-width: 768px) {
flex-direction: column;
align-items: stretch;
& > .semi-space {
width: 100%;
}
&-actions {
width: 100%;
justify-content: flex-start;
margin-top: 0.5rem;
.semi-button {
flex: 1;
}
}
}
}
&__title {
margin: 0 !important;
word-break: break-word;
@media (max-width: 768px) {
font-size: 1.5rem;
}
}
&__image-container {
width: 100%;
height: 400px;
background-color: var(--semi-color-fill-0);
display: flex;
justify-content: center;
align-items: center;
overflow: hidden;
@media (max-width: 768px) {
height: 250px;
}
img {
width: 100%;
height: 100%;
object-fit: contain;
}
}
&__info-section {
padding: 1.5rem;
}
&__map-container {
height: 400px;
width: 100%;
border-radius: var(--semi-border-radius-medium);
margin-top: 1rem;
border: 1px solid var(--semi-color-border);
@media (max-width: 768px) {
height: 300px;
}
}
&__map-wrapper {
margin-top: 2rem;
margin-bottom: 3rem;
}
.info-tag {
font-size: 0.9rem;
padding: 0.2rem 0.6rem;
}
}
.listing-detail-popup {
.map-popup-content {
padding: 5px;
h4 {
margin: 5px 0;
}
}
}

View File

@@ -11,14 +11,14 @@ 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 { IconDelete } 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 'react-range-slider-input/dist/style.css';
import './Map.less';
import { xhrDelete } from '../../services/xhr.js';
import { Link } from 'react-router';
import { Link, useNavigate } from 'react-router-dom';
const { Text } = Typography;
@@ -73,6 +73,7 @@ export default function MapView() {
const markers = useRef([]);
const homeMarker = useRef(null);
const actions = useActions();
const navigate = useNavigate();
const listings = useSelector((state) => state.listingsData.mapListings);
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
const [style, setStyle] = useState('STANDARD');
@@ -113,10 +114,15 @@ export default function MapView() {
}
};
window.viewDetails = (id) => {
navigate(`/listings/listing/${id}`);
};
return () => {
delete window.deleteListing;
delete window.viewDetails;
};
}, []);
}, [navigate]);
useEffect(() => {
if (map.current) return;
@@ -349,7 +355,15 @@ export default function MapView() {
}
};
addCircleLayer();
const updateLayers = () => {
addCircleLayer();
};
if (map.current.isStyleLoaded()) {
updateLayers();
} else {
map.current.on('load', updateLayers);
}
filterListings().forEach((listing) => {
if (
@@ -378,6 +392,13 @@ export default function MapView() {
${renderToString(<IconLink />)}
</a>
</div>
<button
class="map-popup-content__detailsButton"
title="View Details"
onclick="viewDetails('${listing.id}')"
>
${renderToString(<IconEyeOpened />)}
</button>
<button
class="map-popup-content__deleteButton"
title="Remove"

View File

@@ -6,8 +6,9 @@
.map-view-container {
display: flex;
flex-direction: column;
height: calc(100vh - 120px); /* Adjust based on header/footer height */
padding: 1rem;
height: 100%;
padding: 0;
box-sizing: border-box;
}
.map-filter-bar {
@@ -78,6 +79,29 @@
}
}
&__detailsButton {
background: var(--semi-color-tertiary);
color: white;
border: none;
font-size: 14px;
height: 32px;
width: 32px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
cursor: pointer;
padding: 0;
&:hover {
background: var(--semi-color-tertiary-hover);
}
svg {
fill: currentColor;
}
}
&__deleteButton {
background: var(--semi-color-danger);
color: white;

View File

@@ -58,40 +58,46 @@ export default function Login() {
return (
<div className="login">
<div className="login__bgImage" style={{ background: `url("${cityBackground}")` }} />
<Logo />
<form>
<div className="login__loginWrapper">
{error && <Banner type="danger" closeIcon={null} description={error} />}
<Input
size="large"
prefix={<IconUser />}
placeholder="Username"
value={username}
showClear
autoFocus
onChange={(value) => setUserName(value)}
onKeyPress={async (e) => {
if (e.key === 'Enter') {
await tryLogin();
}
}}
/>
<div className="login__loginWrapper">
<div className="login__logoWrapper">
<Logo width={250} white />
</div>
<form onSubmit={(e) => e.preventDefault()}>
{error && <Banner type="danger" closeIcon={null} description={error} style={{ marginBottom: '1rem' }} />}
<div className="login__inputGroup">
<Input
size="large"
prefix={<IconUser />}
placeholder="Username"
value={username}
showClear
autoFocus
onChange={(value) => setUserName(value)}
onKeyPress={async (e) => {
if (e.key === 'Enter') {
await tryLogin();
}
}}
/>
</div>
<Input
size="large"
mode="password"
prefix={<IconLock />}
value={password}
placeholder="Password"
onChange={(value) => setPassword(value)}
onKeyPress={async (e) => {
if (e.key === 'Enter') {
await tryLogin();
}
}}
/>
<div className="login__inputGroup">
<Input
size="large"
mode="password"
prefix={<IconLock />}
value={password}
placeholder="Password"
onChange={(value) => setPassword(value)}
onKeyPress={async (e) => {
if (e.key === 'Enter') {
await tryLogin();
}
}}
/>
</div>
<Button type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '1rem' }}>
<Button block type="primary" onClick={tryLogin} theme="solid" style={{ marginTop: '1rem' }}>
Login
</Button>
@@ -102,10 +108,11 @@ export default function Login() {
bordered
closeIcon={null}
description="This is the demo version of Fredy. Use 'demo' as both the username and password to log in."
style={{ marginTop: '1.5rem' }}
/>
)}
</div>
</form>
</form>
</div>
</div>
);
}

View File

@@ -4,32 +4,80 @@
align-items: center;
width: 100%;
height: 100%;
overflow: hidden;
position: relative;
&__bgImage {
background-size: cover;
filter: blur(8px);
-webkit-filter: blur(8px);
background-position: center;
filter: blur(10px) brightness(0.4);
-webkit-filter: blur(10px) brightness(0.4);
position: absolute;
top: 0;
left: 0;
top: -20px;
left: -20px;
right: -20px;
bottom: -20px;
z-index: 0;
right: 0;
bottom: 0;
}
&__loginWrapper {
border: 1px solid #555050;
border-radius: 30px;
position: relative;
z-index: 1;
background-color: #151313ab;
background: rgba(20, 20, 25, 0.7);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 24px;
padding: 3rem;
width: 90%;
max-width: 420px;
display: flex;
flex-direction: column;
padding: 2rem;
gap: 1rem;
align-items: center;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.7);
}
&__logoWrapper {
margin-bottom: 2.5rem;
display: flex;
justify-content: center;
width: 100%;
.logo {
position: static !important;
max-width: 100%;
height: auto;
}
}
form {
z-index: 1;
width: 100%;
display: flex;
flex-direction: column;
gap: 1.2rem;
}
&__inputGroup {
width: 100%;
}
// Mobile responsiveness
@media (max-width: 480px) {
&__loginWrapper {
padding: 2rem 1.5rem;
width: 95%;
border-radius: 20px;
background: rgba(20, 20, 25, 0.85);
&::after {
opacity: 0.2;
filter: blur(10px);
}
}
&__logoWrapper {
margin-bottom: 1.5rem;
}
}
}