/* * Copyright (c) 2026 by Christian Kellner. * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause */ import { 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, IconExpand, IconGridView, } 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(`
${listing.address}
`)) .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(`${homeAddress.address}
`)) .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 (