diff --git a/package.json b/package.json index 768837f..d33da2d 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "fredy", - "version": "20.1.0", + "version": "20.1.1", "description": "[F]ind [R]eal [E]states [d]amn eas[y].", "scripts": { "prepare": "husky", diff --git a/ui/src/components/grid/listings/ListingsGrid.jsx b/ui/src/components/grid/listings/ListingsGrid.jsx index e47f00c..0050295 100644 --- a/ui/src/components/grid/listings/ListingsGrid.jsx +++ b/ui/src/components/grid/listings/ListingsGrid.jsx @@ -4,6 +4,12 @@ */ import { useState, useEffect, useMemo } from 'react'; +import { + useSearchParamState, + parseNumber, + parseString, + parseNullableBoolean, +} from '../../../hooks/useSearchParamState.js'; import { Card, Col, @@ -35,7 +41,7 @@ import { IconArrowUp, IconArrowDown, } from '@douyinfe/semi-icons'; -import { useNavigate } from 'react-router-dom'; +import { useNavigate, useSearchParams } from 'react-router-dom'; import ListingDeletionModal from '../../ListingDeletionModal.jsx'; import no_image from '../../../assets/no_image.jpg'; import * as timeService from '../../../services/time/timeService.js'; @@ -54,17 +60,18 @@ const ListingsGrid = () => { const jobs = useSelector((state) => state.jobsData.jobs); const actions = useActions(); const navigate = useNavigate(); + const sp = useSearchParams(); - const [page, setPage] = useState(1); + const [page, setPage] = useSearchParamState(sp, 'page', 1, parseNumber); const pageSize = 40; - const [sortField, setSortField] = useState('created_at'); - const [sortDir, setSortDir] = useState('desc'); - const [freeTextFilter, setFreeTextFilter] = useState(null); - const [watchListFilter, setWatchListFilter] = useState(null); - const [jobNameFilter, setJobNameFilter] = useState(null); - const [activityFilter, setActivityFilter] = useState(null); - const [providerFilter, setProviderFilter] = useState(null); + const [sortField, setSortField] = useSearchParamState(sp, 'sort', 'created_at', parseString); + const [sortDir, setSortDir] = useSearchParamState(sp, 'dir', 'desc', parseString); + const [freeTextFilter, setFreeTextFilter] = useSearchParamState(sp, 'q', null, parseString); + const [watchListFilter, setWatchListFilter] = useSearchParamState(sp, 'watch', null, parseNullableBoolean); + const [jobNameFilter, setJobNameFilter] = useSearchParamState(sp, 'job', null, parseString); + const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean); + const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString); const [deleteModalVisible, setDeleteModalVisible] = useState(false); const [listingToDelete, setListingToDelete] = useState(null); @@ -83,7 +90,7 @@ const ListingsGrid = () => { loadData(); }, [page, sortField, sortDir, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]); - const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value), 500), []); + const handleFilterChange = useMemo(() => debounce((value) => setFreeTextFilter(value || null), 500), []); useEffect(() => { return () => { @@ -134,6 +141,7 @@ const ListingsGrid = () => { prefix={} showClear placeholder="Search" + defaultValue={freeTextFilter ?? ''} onChange={handleFilterChange} /> diff --git a/ui/src/hooks/useSearchParamState.js b/ui/src/hooks/useSearchParamState.js new file mode 100644 index 0000000..c2bed37 --- /dev/null +++ b/ui/src/hooks/useSearchParamState.js @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2026 by Christian Kellner. + * Licensed under Apache-2.0 with Commons Clause and Attribution/Naming Clause + */ + +import { useCallback } from 'react'; + +// Preset parsers for common types +export const parseString = { + parse: (v) => v, + stringify: (v) => v, +}; + +export const parseNumber = { + parse: (v) => Number(v), + stringify: (v) => String(v), +}; + +export const parseBoolean = { + parse: (v) => v === 'true', + stringify: (v) => String(v), +}; + +// For state that is null | true | false +export const parseNullableBoolean = { + parse: (v) => (v === 'true' ? true : v === 'false' ? false : null), + stringify: (v) => (v === null ? null : String(v)), +}; + +/** + * Drop-in replacement for useState that syncs with URL search params. + * Uses replace: true so filter changes don't add browser history entries. + * + * Requires a shared [searchParams, setSearchParams] pair from a single + * useSearchParams() call in the component. This ensures multiple hooks + * in the same component don't overwrite each other's params. + * + * @param {[URLSearchParams, Function]} searchParamsPair - from useSearchParams() + * @param {string} key - URL search param key + * @param {*} defaultValue - value when param is absent + * @param {{ parse: (s: string) => *, stringify: (v: *) => string|null }} [options] + */ +export function useSearchParamState([searchParams, setSearchParams], key, defaultValue, options = {}) { + const { parse = (v) => v, stringify = (v) => String(v) } = options; + + const rawValue = searchParams.get(key); + const value = rawValue !== null ? parse(rawValue) : defaultValue; + + const setValue = useCallback( + (newValue) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + const serialized = stringify(newValue); + if (newValue === defaultValue || newValue === null || newValue === undefined || serialized === null) { + next.delete(key); + } else { + next.set(key, serialized); + } + return next; + }, + { replace: true }, + ); + }, + [key, defaultValue, stringify], + ); + + return [value, setValue]; +} diff --git a/ui/src/views/listings/Map.jsx b/ui/src/views/listings/Map.jsx index df6aea9..036667f 100644 --- a/ui/src/views/listings/Map.jsx +++ b/ui/src/views/listings/Map.jsx @@ -4,25 +4,26 @@ */ import { useEffect, useRef, useState } from 'react'; +import { parseBoolean, parseNumber, parseString, useSearchParamState } from '../../hooks/useSearchParamState.js'; import { renderToString } from 'react-dom/server'; import maplibregl from 'maplibre-gl'; import 'maplibre-gl/dist/maplibre-gl.css'; -import { useSelector, useActions } from '../../services/state/store.js'; +import { useActions, useSelector } from '../../services/state/store.js'; import { distanceMeters, generateCircleCoords, getBoundsFromCenter, getBoundsFromCoords } from './mapUtils.js'; -import { Select, Typography, Switch, Banner, Toast } from '@douyinfe/semi-ui-19'; -import { IconLink } from '@douyinfe/semi-icons'; -import { IconDelete, IconEyeOpened } from '@douyinfe/semi-icons'; +import { Banner, Select, Switch, Toast, Typography } from '@douyinfe/semi-ui-19'; +import { IconDelete, IconEyeOpened, IconLink } from '@douyinfe/semi-icons'; import no_image from '../../assets/no_image.jpg'; import _RangeSlider from 'react-range-slider-input'; -const RangeSlider = _RangeSlider?.default ?? _RangeSlider; import 'react-range-slider-input/dist/style.css'; import './Map.less'; import { xhrDelete } from '../../services/xhr.js'; -import { Link, useNavigate } from 'react-router-dom'; +import { Link, useNavigate, useSearchParams } from 'react-router-dom'; import ListingDeletionModal from '../../components/ListingDeletionModal.jsx'; import Map from '../../components/map/Map.jsx'; +const RangeSlider = _RangeSlider?.default ?? _RangeSlider; + const { Text } = Typography; export default function MapView() { @@ -32,15 +33,21 @@ export default function MapView() { const homeMarker = useRef(null); const actions = useActions(); const navigate = useNavigate(); + const sp = useSearchParams(); + const [searchParams, setSearchParams] = sp; const listings = useSelector((state) => state.listingsData.mapListings); const homeAddress = useSelector((state) => state.userSettings.settings.home_address); - const [style, setStyle] = useState('STANDARD'); - const [show3dBuildings, setShow3dBuildings] = useState(false); const jobs = useSelector((state) => state.jobsData.jobs); - const [jobId, setJobId] = useState(null); - const [priceRange, setPriceRange] = useState([0, 0]); - const [distanceFilter, setDistanceFilter] = useState(0); + const [jobId, setJobId] = useSearchParamState(sp, 'job', null, parseString); + const [distanceFilter, setDistanceFilter] = useSearchParamState(sp, 'distance', 0, parseNumber); + const [style] = useSearchParamState(sp, 'style', 'STANDARD', parseString); + const [show3dBuildings, setShow3dBuildings] = useSearchParamState(sp, 'buildings', false, parseBoolean); + + // Price range: stored as priceMin/priceMax URL params; default max derived from loaded listings + const urlPriceMin = searchParams.has('priceMin') ? Number(searchParams.get('priceMin')) : null; + const urlPriceMax = searchParams.has('priceMax') ? Number(searchParams.get('priceMax')) : null; + const [priceRange, setPriceRange] = useState([urlPriceMin ?? 0, urlPriceMax ?? 0]); const [deleteModalVisible, setDeleteModalVisible] = useState(false); const [listingToDelete, setListingToDelete] = useState(null); @@ -59,14 +66,17 @@ export default function MapView() { }; useEffect(() => { - setPriceRange([0, getMaxPrice()]); + // Only reset to full range when no URL override is set + if (urlPriceMax === null) { + setPriceRange([0, getMaxPrice()]); + } }, [listings]); const getMaxPrice = () => { - return listings.reduce((max, item) => { + return listings.reduce((acc, item) => { const price = Number(item.price); - return Number.isFinite(price) && price > max ? price : max; - }, -Infinity); + return Number.isFinite(price) && price > acc ? price : acc; + }, 0); }; const filterListings = () => { @@ -109,11 +119,45 @@ export default function MapView() { map.current = mapInstance; }; - const setMapStyle = (value) => { - setStyle(value); - if (value === 'SATELLITE') { - setShow3dBuildings(false); - } + const handleMapStyle = (value) => { + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (value === 'STANDARD') { + next.delete('style'); + } else { + next.set('style', value); + } + if (value === 'SATELLITE') { + next.delete('buildings'); + } + return next; + }, + { replace: true }, + ); + }; + + const handlePriceRange = (val) => { + const maxPrice = getMaxPrice(); + if (maxPrice <= 0) return; // skip until listings are loaded + setPriceRange(val); + setSearchParams( + (prev) => { + const next = new URLSearchParams(prev); + if (val[0] === 0) { + next.delete('priceMin'); + } else { + next.set('priceMin', String(val[0])); + } + if (val[1] === 0 || val[1] >= maxPrice) { + next.delete('priceMax'); + } else { + next.set('priceMax', String(val[1])); + } + return next; + }, + { replace: true }, + ); }; const fetchListings = async () => { @@ -395,13 +439,7 @@ export default function MapView() { {priceRange[0]} {priceRange[1]} - setPriceRange(val)} - /> + @@ -409,7 +447,7 @@ export default function MapView() { Style - handleMapStyle(val)} style={{ width: 110 }}> Standard Satellite