mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
storing filter settings in url
This commit is contained in:
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "fredy",
|
"name": "fredy",
|
||||||
"version": "20.1.0",
|
"version": "20.1.1",
|
||||||
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
"description": "[F]ind [R]eal [E]states [d]amn eas[y].",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"prepare": "husky",
|
"prepare": "husky",
|
||||||
|
|||||||
@@ -4,6 +4,12 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useState, useEffect, useMemo } from 'react';
|
import { useState, useEffect, useMemo } from 'react';
|
||||||
|
import {
|
||||||
|
useSearchParamState,
|
||||||
|
parseNumber,
|
||||||
|
parseString,
|
||||||
|
parseNullableBoolean,
|
||||||
|
} from '../../../hooks/useSearchParamState.js';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
Col,
|
Col,
|
||||||
@@ -35,7 +41,7 @@ import {
|
|||||||
IconArrowUp,
|
IconArrowUp,
|
||||||
IconArrowDown,
|
IconArrowDown,
|
||||||
} from '@douyinfe/semi-icons';
|
} from '@douyinfe/semi-icons';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate, useSearchParams } from 'react-router-dom';
|
||||||
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
import ListingDeletionModal from '../../ListingDeletionModal.jsx';
|
||||||
import no_image from '../../../assets/no_image.jpg';
|
import no_image from '../../../assets/no_image.jpg';
|
||||||
import * as timeService from '../../../services/time/timeService.js';
|
import * as timeService from '../../../services/time/timeService.js';
|
||||||
@@ -54,17 +60,18 @@ const ListingsGrid = () => {
|
|||||||
const jobs = useSelector((state) => state.jobsData.jobs);
|
const jobs = useSelector((state) => state.jobsData.jobs);
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const sp = useSearchParams();
|
||||||
|
|
||||||
const [page, setPage] = useState(1);
|
const [page, setPage] = useSearchParamState(sp, 'page', 1, parseNumber);
|
||||||
const pageSize = 40;
|
const pageSize = 40;
|
||||||
|
|
||||||
const [sortField, setSortField] = useState('created_at');
|
const [sortField, setSortField] = useSearchParamState(sp, 'sort', 'created_at', parseString);
|
||||||
const [sortDir, setSortDir] = useState('desc');
|
const [sortDir, setSortDir] = useSearchParamState(sp, 'dir', 'desc', parseString);
|
||||||
const [freeTextFilter, setFreeTextFilter] = useState(null);
|
const [freeTextFilter, setFreeTextFilter] = useSearchParamState(sp, 'q', null, parseString);
|
||||||
const [watchListFilter, setWatchListFilter] = useState(null);
|
const [watchListFilter, setWatchListFilter] = useSearchParamState(sp, 'watch', null, parseNullableBoolean);
|
||||||
const [jobNameFilter, setJobNameFilter] = useState(null);
|
const [jobNameFilter, setJobNameFilter] = useSearchParamState(sp, 'job', null, parseString);
|
||||||
const [activityFilter, setActivityFilter] = useState(null);
|
const [activityFilter, setActivityFilter] = useSearchParamState(sp, 'active', null, parseNullableBoolean);
|
||||||
const [providerFilter, setProviderFilter] = useState(null);
|
const [providerFilter, setProviderFilter] = useSearchParamState(sp, 'provider', null, parseString);
|
||||||
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
const [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||||
const [listingToDelete, setListingToDelete] = useState(null);
|
const [listingToDelete, setListingToDelete] = useState(null);
|
||||||
|
|
||||||
@@ -83,7 +90,7 @@ const ListingsGrid = () => {
|
|||||||
loadData();
|
loadData();
|
||||||
}, [page, sortField, sortDir, freeTextFilter, providerFilter, activityFilter, jobNameFilter, watchListFilter]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
return () => {
|
return () => {
|
||||||
@@ -134,6 +141,7 @@ const ListingsGrid = () => {
|
|||||||
prefix={<IconSearch />}
|
prefix={<IconSearch />}
|
||||||
showClear
|
showClear
|
||||||
placeholder="Search"
|
placeholder="Search"
|
||||||
|
defaultValue={freeTextFilter ?? ''}
|
||||||
onChange={handleFilterChange}
|
onChange={handleFilterChange}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
69
ui/src/hooks/useSearchParamState.js
Normal file
69
ui/src/hooks/useSearchParamState.js
Normal 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];
|
||||||
|
}
|
||||||
@@ -4,25 +4,26 @@
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from 'react';
|
import { useEffect, useRef, useState } from 'react';
|
||||||
|
import { parseBoolean, parseNumber, parseString, useSearchParamState } from '../../hooks/useSearchParamState.js';
|
||||||
import { renderToString } from 'react-dom/server';
|
import { renderToString } from 'react-dom/server';
|
||||||
import maplibregl from 'maplibre-gl';
|
import maplibregl from 'maplibre-gl';
|
||||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
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 { distanceMeters, generateCircleCoords, getBoundsFromCenter, getBoundsFromCoords } from './mapUtils.js';
|
||||||
import { Select, Typography, Switch, Banner, Toast } from '@douyinfe/semi-ui-19';
|
import { Banner, Select, Switch, Toast, Typography } from '@douyinfe/semi-ui-19';
|
||||||
import { IconLink } from '@douyinfe/semi-icons';
|
import { IconDelete, IconEyeOpened, IconLink } from '@douyinfe/semi-icons';
|
||||||
import { IconDelete, IconEyeOpened } from '@douyinfe/semi-icons';
|
|
||||||
|
|
||||||
import no_image from '../../assets/no_image.jpg';
|
import no_image from '../../assets/no_image.jpg';
|
||||||
import _RangeSlider from 'react-range-slider-input';
|
import _RangeSlider from 'react-range-slider-input';
|
||||||
const RangeSlider = _RangeSlider?.default ?? _RangeSlider;
|
|
||||||
import 'react-range-slider-input/dist/style.css';
|
import 'react-range-slider-input/dist/style.css';
|
||||||
import './Map.less';
|
import './Map.less';
|
||||||
import { xhrDelete } from '../../services/xhr.js';
|
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 ListingDeletionModal from '../../components/ListingDeletionModal.jsx';
|
||||||
import Map from '../../components/map/Map.jsx';
|
import Map from '../../components/map/Map.jsx';
|
||||||
|
|
||||||
|
const RangeSlider = _RangeSlider?.default ?? _RangeSlider;
|
||||||
|
|
||||||
const { Text } = Typography;
|
const { Text } = Typography;
|
||||||
|
|
||||||
export default function MapView() {
|
export default function MapView() {
|
||||||
@@ -32,15 +33,21 @@ export default function MapView() {
|
|||||||
const homeMarker = useRef(null);
|
const homeMarker = useRef(null);
|
||||||
const actions = useActions();
|
const actions = useActions();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const sp = useSearchParams();
|
||||||
|
const [searchParams, setSearchParams] = sp;
|
||||||
const listings = useSelector((state) => state.listingsData.mapListings);
|
const listings = useSelector((state) => state.listingsData.mapListings);
|
||||||
const homeAddress = useSelector((state) => state.userSettings.settings.home_address);
|
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 jobs = useSelector((state) => state.jobsData.jobs);
|
||||||
const [jobId, setJobId] = useState(null);
|
const [jobId, setJobId] = useSearchParamState(sp, 'job', null, parseString);
|
||||||
const [priceRange, setPriceRange] = useState([0, 0]);
|
const [distanceFilter, setDistanceFilter] = useSearchParamState(sp, 'distance', 0, parseNumber);
|
||||||
const [distanceFilter, setDistanceFilter] = useState(0);
|
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 [deleteModalVisible, setDeleteModalVisible] = useState(false);
|
||||||
const [listingToDelete, setListingToDelete] = useState(null);
|
const [listingToDelete, setListingToDelete] = useState(null);
|
||||||
@@ -59,14 +66,17 @@ export default function MapView() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
// Only reset to full range when no URL override is set
|
||||||
|
if (urlPriceMax === null) {
|
||||||
setPriceRange([0, getMaxPrice()]);
|
setPriceRange([0, getMaxPrice()]);
|
||||||
|
}
|
||||||
}, [listings]);
|
}, [listings]);
|
||||||
|
|
||||||
const getMaxPrice = () => {
|
const getMaxPrice = () => {
|
||||||
return listings.reduce((max, item) => {
|
return listings.reduce((acc, item) => {
|
||||||
const price = Number(item.price);
|
const price = Number(item.price);
|
||||||
return Number.isFinite(price) && price > max ? price : max;
|
return Number.isFinite(price) && price > acc ? price : acc;
|
||||||
}, -Infinity);
|
}, 0);
|
||||||
};
|
};
|
||||||
|
|
||||||
const filterListings = () => {
|
const filterListings = () => {
|
||||||
@@ -109,11 +119,45 @@ export default function MapView() {
|
|||||||
map.current = mapInstance;
|
map.current = mapInstance;
|
||||||
};
|
};
|
||||||
|
|
||||||
const setMapStyle = (value) => {
|
const handleMapStyle = (value) => {
|
||||||
setStyle(value);
|
setSearchParams(
|
||||||
if (value === 'SATELLITE') {
|
(prev) => {
|
||||||
setShow3dBuildings(false);
|
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 () => {
|
const fetchListings = async () => {
|
||||||
@@ -395,13 +439,7 @@ export default function MapView() {
|
|||||||
<span>{priceRange[0]}</span>
|
<span>{priceRange[0]}</span>
|
||||||
<span>{priceRange[1]}</span>
|
<span>{priceRange[1]}</span>
|
||||||
</div>
|
</div>
|
||||||
<RangeSlider
|
<RangeSlider min={0} max={getMaxPrice()} step={100} value={priceRange} onInput={handlePriceRange} />
|
||||||
min={0}
|
|
||||||
max={getMaxPrice()}
|
|
||||||
step={100}
|
|
||||||
value={priceRange}
|
|
||||||
onInput={(val) => setPriceRange(val)}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -409,7 +447,7 @@ export default function MapView() {
|
|||||||
<Text size="small" strong style={{ color: '#8892a4' }}>
|
<Text size="small" strong style={{ color: '#8892a4' }}>
|
||||||
Style
|
Style
|
||||||
</Text>
|
</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="STANDARD">Standard</Select.Option>
|
||||||
<Select.Option value="SATELLITE">Satellite</Select.Option>
|
<Select.Option value="SATELLITE">Satellite</Select.Option>
|
||||||
</Select>
|
</Select>
|
||||||
|
|||||||
Reference in New Issue
Block a user