From 726f919609ad1765d94bb02b2436e0aab369f1d1 Mon Sep 17 00:00:00 2001 From: Tiny Paws Date: Tue, 23 Sep 2025 16:15:13 +0200 Subject: [PATCH 1/5] Aligning with OSM usage policies Added 24 hours cache on Nominatim requests Added rate limiting on Nominatim requests Added OpenStreetMap attribution on leaflet map --- web/src/components/LeafletMap.tsx | 1 + .../ActionButton/LocationSelector.tsx | 32 ++++++++++++++++--- 2 files changed, 29 insertions(+), 4 deletions(-) diff --git a/web/src/components/LeafletMap.tsx b/web/src/components/LeafletMap.tsx index e1facc54cb057..8d5f66a04b4a7 100644 --- a/web/src/components/LeafletMap.tsx +++ b/web/src/components/LeafletMap.tsx @@ -34,6 +34,7 @@ const LocationMarker = (props: MarkerProps) => { useEffect(() => { map.attributionControl.setPrefix(""); + map.attributionControl.addAttribution("OpenStreetMap"); map.locate(); }, []); diff --git a/web/src/components/MemoEditor/ActionButton/LocationSelector.tsx b/web/src/components/MemoEditor/ActionButton/LocationSelector.tsx index d101bb06418a8..e7d015a3bc709 100644 --- a/web/src/components/MemoEditor/ActionButton/LocationSelector.tsx +++ b/web/src/components/MemoEditor/ActionButton/LocationSelector.tsx @@ -1,6 +1,6 @@ import { LatLng } from "leaflet"; import { MapPinIcon, XIcon } from "lucide-react"; -import { useEffect, useState } from "react"; +import { useEffect, useState, useRef } from "react"; import toast from "react-hot-toast"; import LeafletMap from "@/components/LeafletMap"; import { Button } from "@/components/ui/button"; @@ -21,6 +21,12 @@ interface State { position?: LatLng; } +interface NomatimRateLimit { + lastNominatimFetch: Date; + nominatimTimeoutId: number | undefined; + timeBetweenFetch: number; +} + const LocationSelector = (props: Props) => { const t = useTranslate(); const [state, setState] = useState({ @@ -28,6 +34,12 @@ const LocationSelector = (props: Props) => { placeholder: props.location?.placeholder || "", position: props.location ? new LatLng(props.location.latitude, props.location.longitude) : undefined, }); + const rateLimit = useRef({ + lastNominatimFetch: new Date(0), + nominatimTimeoutId: undefined, + timeBetweenFetch: 1300, + }); + const [popoverOpen, setPopoverOpen] = useState(false); useEffect(() => { @@ -63,14 +75,16 @@ const LocationSelector = (props: Props) => { } }, [popoverOpen]); - useEffect(() => { + const updateReverseGeocoding = () => { if (!state.position) { setState({ ...state, placeholder: "" }); return; } - // Fetch reverse geocoding data. - fetch(`https://nominatim.openstreetmap.org/reverse?lat=${state.position.lat}&lon=${state.position.lng}&format=json`) + fetch(`https://nominatim.openstreetmap.org/reverse?lat=${state.position.lat}&lon=${state.position.lng}&format=json`, { + cache: "default", + headers: new Headers({ "Cache-Control": "max-age=86400" }), + }) .then((response) => response.json()) .then((data) => { if (data && data.display_name) { @@ -81,6 +95,16 @@ const LocationSelector = (props: Props) => { toast.error("Failed to fetch reverse geocoding data"); console.error("Failed to fetch reverse geocoding data:", error); }); + } + + useEffect(() => { + // Fetch reverse geocoding with rate limits + clearTimeout(rateLimit.current.nominatimTimeoutId); + const timeLeft = rateLimit.current.timeBetweenFetch - (new Date().getTime() - rateLimit.current.lastNominatimFetch.getTime()); + rateLimit.current.nominatimTimeoutId = setTimeout(() => { + updateReverseGeocoding(); + rateLimit.current.lastNominatimFetch = new Date(); + }, timeLeft); }, [state.position]); const onPositionChanged = (position: LatLng) => { From 83ccc30554d84f52e97d7c31c90131790052735a Mon Sep 17 00:00:00 2001 From: Tiny Paws Date: Wed, 1 Oct 2025 20:31:44 +0200 Subject: [PATCH 2/5] Fixed typo and appeased linter --- .../components/MemoEditor/ActionButton/LocationSelector.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/web/src/components/MemoEditor/ActionButton/LocationSelector.tsx b/web/src/components/MemoEditor/ActionButton/LocationSelector.tsx index e7d015a3bc709..3cda7884db5d8 100644 --- a/web/src/components/MemoEditor/ActionButton/LocationSelector.tsx +++ b/web/src/components/MemoEditor/ActionButton/LocationSelector.tsx @@ -21,7 +21,7 @@ interface State { position?: LatLng; } -interface NomatimRateLimit { +interface NominatimRateLimit { lastNominatimFetch: Date; nominatimTimeoutId: number | undefined; timeBetweenFetch: number; @@ -34,7 +34,7 @@ const LocationSelector = (props: Props) => { placeholder: props.location?.placeholder || "", position: props.location ? new LatLng(props.location.latitude, props.location.longitude) : undefined, }); - const rateLimit = useRef({ + const rateLimit = useRef({ lastNominatimFetch: new Date(0), nominatimTimeoutId: undefined, timeBetweenFetch: 1300, @@ -95,7 +95,7 @@ const LocationSelector = (props: Props) => { toast.error("Failed to fetch reverse geocoding data"); console.error("Failed to fetch reverse geocoding data:", error); }); - } + }; useEffect(() => { // Fetch reverse geocoding with rate limits From e3fe6ec6d96ae803bf1423020f01fdc69a603785 Mon Sep 17 00:00:00 2001 From: Johnny Date: Thu, 9 Oct 2025 00:43:18 +0800 Subject: [PATCH 3/5] Apply suggestion from @Copilot Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> Signed-off-by: Johnny --- web/src/components/MemoEditor/ActionButton/LocationSelector.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/web/src/components/MemoEditor/ActionButton/LocationSelector.tsx b/web/src/components/MemoEditor/ActionButton/LocationSelector.tsx index 3cda7884db5d8..3f5c0a47b4753 100644 --- a/web/src/components/MemoEditor/ActionButton/LocationSelector.tsx +++ b/web/src/components/MemoEditor/ActionButton/LocationSelector.tsx @@ -104,7 +104,7 @@ const LocationSelector = (props: Props) => { rateLimit.current.nominatimTimeoutId = setTimeout(() => { updateReverseGeocoding(); rateLimit.current.lastNominatimFetch = new Date(); - }, timeLeft); + }, Math.max(0, timeLeft)); }, [state.position]); const onPositionChanged = (position: LatLng) => { From e579a02435fab1296ea7e0b4ca180f6c06cc7302 Mon Sep 17 00:00:00 2001 From: Tiny Paws Date: Tue, 14 Oct 2025 19:55:49 +0200 Subject: [PATCH 4/5] Fixed linter errors --- .../MemoEditor/ActionButton/LocationSelector.tsx | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/web/src/components/MemoEditor/ActionButton/LocationSelector.tsx b/web/src/components/MemoEditor/ActionButton/LocationSelector.tsx index 3f5c0a47b4753..40dfe2743bfbe 100644 --- a/web/src/components/MemoEditor/ActionButton/LocationSelector.tsx +++ b/web/src/components/MemoEditor/ActionButton/LocationSelector.tsx @@ -101,10 +101,13 @@ const LocationSelector = (props: Props) => { // Fetch reverse geocoding with rate limits clearTimeout(rateLimit.current.nominatimTimeoutId); const timeLeft = rateLimit.current.timeBetweenFetch - (new Date().getTime() - rateLimit.current.lastNominatimFetch.getTime()); - rateLimit.current.nominatimTimeoutId = setTimeout(() => { - updateReverseGeocoding(); - rateLimit.current.lastNominatimFetch = new Date(); - }, Math.max(0, timeLeft)); + rateLimit.current.nominatimTimeoutId = setTimeout( + () => { + updateReverseGeocoding(); + rateLimit.current.lastNominatimFetch = new Date(); + }, + Math.max(0, timeLeft), + ); }, [state.position]); const onPositionChanged = (position: LatLng) => { From 0d0d2e6ee2562dc5790799eb84a1455dca4fcfbb Mon Sep 17 00:00:00 2001 From: Tiny Paws Date: Tue, 14 Oct 2025 20:07:39 +0200 Subject: [PATCH 5/5] Fixed merge conflict --- .../ActionButton/LocationSelector.tsx | 124 ++++++++++++++---- 1 file changed, 97 insertions(+), 27 deletions(-) diff --git a/web/src/components/MemoEditor/ActionButton/LocationSelector.tsx b/web/src/components/MemoEditor/ActionButton/LocationSelector.tsx index 40dfe2743bfbe..b43809b83ecbe 100644 --- a/web/src/components/MemoEditor/ActionButton/LocationSelector.tsx +++ b/web/src/components/MemoEditor/ActionButton/LocationSelector.tsx @@ -5,7 +5,9 @@ import toast from "react-hot-toast"; import LeafletMap from "@/components/LeafletMap"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; import { Popover, PopoverContent, PopoverTrigger } from "@/components/ui/popover"; +import { Textarea } from "@/components/ui/textarea"; import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"; import { Location } from "@/types/proto/api/v1/memo_service"; import { useTranslate } from "@/utils/i18n"; @@ -16,9 +18,11 @@ interface Props { } interface State { - initilized: boolean; + initialized: boolean; placeholder: string; position?: LatLng; + latInput: string; + lngInput: string; } interface NominatimRateLimit { @@ -30,9 +34,11 @@ interface NominatimRateLimit { const LocationSelector = (props: Props) => { const t = useTranslate(); const [state, setState] = useState({ - initilized: false, + initialized: false, placeholder: props.location?.placeholder || "", position: props.location ? new LatLng(props.location.latitude, props.location.longitude) : undefined, + latInput: props.location ? String(props.location.latitude) : "", + lngInput: props.location ? String(props.location.longitude) : "", }); const rateLimit = useRef({ lastNominatimFetch: new Date(0), @@ -47,13 +53,15 @@ const LocationSelector = (props: Props) => { ...state, placeholder: props.location?.placeholder || "", position: new LatLng(props.location?.latitude || 0, props.location?.longitude || 0), + latInput: String(props.location?.latitude) || "", + lngInput: String(props.location?.longitude) || "", })); }, [props.location]); useEffect(() => { if (popoverOpen && !props.location) { const handleError = (error: any, errorMessage: string) => { - setState({ ...state, initilized: true }); + setState((prev) => ({ ...prev, initialized: true })); toast.error(errorMessage); console.error(error); }; @@ -63,7 +71,13 @@ const LocationSelector = (props: Props) => { (position) => { const lat = position.coords.latitude; const lng = position.coords.longitude; - setState({ ...state, position: new LatLng(lat, lng), initilized: true }); + setState((prev) => ({ + ...prev, + position: new LatLng(lat, lng), + latInput: String(lat), + lngInput: String(lng), + initialized: true, + })); }, (error) => { handleError(error, "Failed to get current position"); @@ -73,14 +87,21 @@ const LocationSelector = (props: Props) => { handleError("Geolocation is not supported by this browser.", "Geolocation is not supported by this browser."); } } - }, [popoverOpen]); + }, [popoverOpen, props.location]); const updateReverseGeocoding = () => { if (!state.position) { - setState({ ...state, placeholder: "" }); + setState((prev) => ({ ...prev, placeholder: "" })); return; } + const newLat = String(state.position.lat); + const newLng = String(state.position.lng); + if (state.latInput !== newLat || state.lngInput !== newLng) { + setState((prev) => ({ ...prev, latInput: newLat, lngInput: newLng })); + } + + // Fetch reverse geocoding data. fetch(`https://nominatim.openstreetmap.org/reverse?lat=${state.position.lat}&lon=${state.position.lng}&format=json`, { cache: "default", headers: new Headers({ "Cache-Control": "max-age=86400" }), @@ -88,7 +109,7 @@ const LocationSelector = (props: Props) => { .then((response) => response.json()) .then((data) => { if (data && data.display_name) { - setState({ ...state, placeholder: data.display_name }); + setState((prev) => ({ ...prev, placeholder: data.display_name })); } }) .catch((error) => { @@ -110,8 +131,20 @@ const LocationSelector = (props: Props) => { ); }, [state.position]); + // Update position when lat/lng inputs change (if valid numbers) + useEffect(() => { + const lat = parseFloat(state.latInput); + const lng = parseFloat(state.lngInput); + // Validate coordinate ranges: lat must be -90 to 90, lng must be -180 to 180 + if (Number.isFinite(lat) && Number.isFinite(lng) && lat >= -90 && lat <= 90 && lng >= -180 && lng <= 180) { + if (!state.position || state.position.lat !== lat || state.position.lng !== lng) { + setState((prev) => ({ ...prev, position: new LatLng(lat, lng) })); + } + } + }, [state.latInput, state.lngInput]); + const onPositionChanged = (position: LatLng) => { - setState({ ...state, position }); + setState((prev) => ({ ...prev, position })); }; const removeLocation = (e: React.MouseEvent) => { @@ -148,29 +181,66 @@ const LocationSelector = (props: Props) => { )} - -
- -
-
-
- {state.position && ( -
- [{state.position.lat.toFixed(2)}, {state.position.lng.toFixed(2)}] -
- )} + +
+
+ +
+
+
+
+ + setState((prev) => ({ ...prev, latInput: e.target.value }))} + className="h-9" + /> +
+
+ setState((state) => ({ ...state, placeholder: e.target.value }))} + id="memo-location-lng" + placeholder="Lng" + type="number" + step="any" + min="-180" + max="180" + value={state.lngInput} + onChange={(e) => setState((prev) => ({ ...prev, lngInput: e.target.value }))} + className="h-9" />
+
+ +