storing filter settings in url

This commit is contained in:
orangecoding
2026-03-31 11:46:22 +02:00
parent bc73de6703
commit 1c9d7c9d92
4 changed files with 154 additions and 39 deletions

View File

@@ -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",

View File

@@ -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={<IconSearch />}
showClear
placeholder="Search"
defaultValue={freeTextFilter ?? ''}
onChange={handleFilterChange}
/>

View File

@@ -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];
}

View File

@@ -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() {
<span>{priceRange[0]}</span>
<span>{priceRange[1]}</span>
</div>
<RangeSlider
min={0}
max={getMaxPrice()}
step={100}
value={priceRange}
onInput={(val) => setPriceRange(val)}
/>
<RangeSlider min={0} max={getMaxPrice()} step={100} value={priceRange} onInput={handlePriceRange} />
</div>
</div>
@@ -409,7 +447,7 @@ export default function MapView() {
<Text size="small" strong style={{ color: '#8892a4' }}>
Style
</Text>
<Select size="small" value={style} onChange={(val) => setMapStyle(val)} style={{ width: 110 }}>
<Select size="small" value={style} onChange={(val) => handleMapStyle(val)} style={{ width: 110 }}>
<Select.Option value="STANDARD">Standard</Select.Option>
<Select.Option value="SATELLITE">Satellite</Select.Option>
</Select>