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
-