mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
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:
committed by
GitHub
parent
3117044139
commit
472169693f
383
ui/src/views/listings/ListingDetail.jsx
Normal file
383
ui/src/views/listings/ListingDetail.jsx
Normal 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} m²` : '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>
|
||||
);
|
||||
}
|
||||
110
ui/src/views/listings/ListingDetail.less
Normal file
110
ui/src/views/listings/ListingDetail.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user