From 6ece482bf6848c993f62811c73f3e04e2b9d5f25 Mon Sep 17 00:00:00 2001 From: Ian Date: Fri, 25 Sep 2026 08:05:45 +0100 Subject: [PATCH 1/2] trigs: device location by default, map layer picker and location marker - Location defaults to the device's current position on every visit, falling back to Buxton only when location access is blocked or times out. Only a place the user picks is kept in the URL, so refreshes follow the device and shared links don't carry the sharer's position; old links with saved "Current location" coordinates use the device instead - Map: Map Layer picker (shared preference with the main map), keeping the view across a change of projection - Map: the Location is marked with a light blue circle, named on hover - Download is a quiet text button, below the List/Map toggle in emphasis Co-Authored-By: Claude Opus 5.5 --- web/src/components/experiment/TrigsV2Map.tsx | 109 +++++++++++++++---- web/src/components/trigs/DownloadButton.tsx | 12 +- web/src/routes/experiment/TrigsV2.tsx | 48 +++++--- 3 files changed, 130 insertions(+), 39 deletions(-) diff --git a/web/src/components/experiment/TrigsV2Map.tsx b/web/src/components/experiment/TrigsV2Map.tsx index ef5e36d0..a4b1384f 100644 --- a/web/src/components/experiment/TrigsV2Map.tsx +++ b/web/src/components/experiment/TrigsV2Map.tsx @@ -6,12 +6,13 @@ * viewport and switches to a heatmap when too many would be visible. */ -import { useEffect, useMemo, useState } from "react"; -import { useMap } from "react-leaflet"; +import { useCallback, useEffect, useMemo, useRef, useState, type MutableRefObject } from "react"; +import { CircleMarker, Tooltip, useMap } from "react-leaflet"; import { latLngBounds } from "leaflet"; import BaseMap from "../map/BaseMap"; import TrigMarker from "../map/TrigMarker"; import HeatmapLayer from "../map/HeatmapLayer"; +import TilesetSelector from "../map/TilesetSelector"; import AddToListButton from "../lists/AddToListButton"; import type { MapBounds, TrigData } from "../map/types"; import { @@ -24,7 +25,18 @@ import { // Above this many markers in view, show a density heatmap instead const MAX_VISIBLE_MARKERS = 1000; -function ViewportTracker({ onChange }: { onChange: (bounds: MapBounds) => void }) { +interface MapView { + center: [number, number]; + zoom: number; +} + +function ViewportTracker({ + onChange, + viewRef, +}: { + onChange: (bounds: MapBounds) => void; + viewRef: MutableRefObject; +}) { const map = useMap(); useEffect(() => { @@ -36,13 +48,15 @@ function ViewportTracker({ onChange }: { onChange: (bounds: MapBounds) => void } east: bounds.getEast(), west: bounds.getWest(), }); + const center = map.getCenter(); + viewRef.current = { center: [center.lat, center.lng], zoom: map.getZoom() }; }; update(); map.on("moveend", update); return () => { map.off("moveend", update); }; - }, [map, onChange]); + }, [map, onChange, viewRef]); return null; } @@ -52,11 +66,23 @@ function ViewportTracker({ onChange }: { onChange: (bounds: MapBounds) => void } // few bad positions can't drag the map off the UK. const FIT_REGION = { south: 49, north: 61.5, west: -11, east: 2.5 }; -/** Zoom to fit the trigpoints whenever the filtered set changes. */ -function FitToTrigs({ trigs }: { trigs: TrigData[] }) { +/** + * Zoom to fit the trigpoints whenever the filtered set changes. `fittedRef` + * lives outside the map, so a remount (e.g. switching to a layer with a + * different projection) keeps the user's view rather than fitting again. + */ +function FitToTrigs({ + trigs, + fittedRef, +}: { + trigs: TrigData[]; + fittedRef: MutableRefObject; +}) { const map = useMap(); useEffect(() => { + if (fittedRef.current === trigs) return; + fittedRef.current = trigs; const points = trigs .map((t) => [Number(t.wgs_lat), Number(t.wgs_long)] as [number, number]) .filter( @@ -68,7 +94,7 @@ function FitToTrigs({ trigs }: { trigs: TrigData[] }) { ); if (points.length === 0) return; map.fitBounds(latLngBounds(points), { padding: [24, 24], maxZoom: 13 }); - }, [map, trigs]); + }, [map, trigs, fittedRef]); return null; } @@ -79,6 +105,8 @@ export interface TrigsV2MapProps { error: Error | null; truncated: boolean; showListActions: boolean; + /** The page's Location, marked on the map */ + location?: { lat: number; lon: number; name: string }; } export function TrigsV2Map({ @@ -87,19 +115,45 @@ export function TrigsV2Map({ error, truncated, showListActions, + location, }: TrigsV2MapProps) { - const [tileLayerId] = useState(getPreferredTileLayer); + const [tileLayerId, setTileLayerId] = useState(getPreferredTileLayer); const [bounds, setBounds] = useState(null); + const fittedRef = useRef(null); - const initialZoom = useMemo(() => { + // The view the map starts from - only read when it mounts, i.e. first time + // and whenever a change of projection remounts it + const [startView, setStartView] = useState(() => { const layer = getTileLayer(tileLayerId); - return calculateProjectionZoom( - MAP_CONFIG.defaultZoom, - "EPSG:3857", - layer.crs || "EPSG:3857", - layer, - ); - }, [tileLayerId]); + return { + center: [MAP_CONFIG.defaultCenter.lat, MAP_CONFIG.defaultCenter.lng], + zoom: calculateProjectionZoom( + MAP_CONFIG.defaultZoom, + "EPSG:3857", + layer.crs || "EPSG:3857", + layer, + ), + }; + }); + const viewRef = useRef(startView); + + // Keep the current view across a projection change (zoom levels differ + // between projections, so convert it) + const handleTilesetChange = useCallback( + (newTileLayerId: string) => { + const currentCrs = getTileLayer(tileLayerId).crs || "EPSG:3857"; + const newLayer = getTileLayer(newTileLayerId); + const newCrs = newLayer.crs || "EPSG:3857"; + if (currentCrs !== newCrs) { + setStartView({ + center: viewRef.current.center, + zoom: calculateProjectionZoom(viewRef.current.zoom, currentCrs, newCrs, newLayer), + }); + } + setTileLayerId(newTileLayerId); + }, + [tileLayerId], + ); const visibleTrigs = useMemo(() => { if (!bounds) return trigs; @@ -146,13 +200,13 @@ export function TrigsV2Map({ {/* relative z-0 contains Leaflet's pane z-indexes so the sticky footer stays on top */}
- - + + {showHeatmap ? ( ) : ( @@ -165,7 +219,22 @@ export function TrigsV2Map({ /> )) )} + {location && ( + // Same blue as the location circle on the popup mini-maps + + {location.name && {location.name}} + + )} + + {/* Above Leaflet's controls (z-index 1000) */} +
+ +
); diff --git a/web/src/components/trigs/DownloadButton.tsx b/web/src/components/trigs/DownloadButton.tsx index 63aec48c..f403a145 100644 --- a/web/src/components/trigs/DownloadButton.tsx +++ b/web/src/components/trigs/DownloadButton.tsx @@ -28,10 +28,19 @@ interface DownloadButtonProps { order?: string; /** Whose logs "include log data" refers to, when not the signed-in user */ logUserName?: string; + /** "subtle" for a quiet text button beside more important controls */ + variant?: "primary" | "subtle"; /** Additional CSS classes */ className?: string; } +const BUTTON_STYLES = { + primary: + "gap-2 px-4 py-2 font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg shadow-sm", + subtle: + "gap-1.5 px-2 py-1.5 text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 hover:bg-gray-100 dark:hover:bg-gray-700 rounded-lg", +}; + type DownloadFormat = "csv" | "geojson" | "kml" | "kmz" | "gpx"; interface FormatOption { @@ -59,6 +68,7 @@ export function DownloadButton({ filterParams, order, logUserName, + variant = "primary", className = "", }: DownloadButtonProps) { const [isOpen, setIsOpen] = useState(false); @@ -181,7 +191,7 @@ export function DownloadButton({ ref={refs.setReference} type="button" onClick={() => setIsOpen(!isOpen)} - className="inline-flex items-center gap-2 px-4 py-2 text-sm font-medium text-white bg-green-600 hover:bg-green-700 rounded-lg shadow-sm transition-colors disabled:opacity-50" + className={`inline-flex items-center text-sm transition-colors disabled:opacity-50 ${BUTTON_STYLES[variant]}`} disabled={isLoading} > {isLoading ? ( diff --git a/web/src/routes/experiment/TrigsV2.tsx b/web/src/routes/experiment/TrigsV2.tsx index 0ef0a1ae..a2664ca6 100644 --- a/web/src/routes/experiment/TrigsV2.tsx +++ b/web/src/routes/experiment/TrigsV2.tsx @@ -56,6 +56,9 @@ const DEFAULT_LAT = 53.2585; const DEFAULT_LON = -1.9106; const DEFAULT_LOCATION_NAME = "Buxton"; +// What LocationSearch calls the device's own location +const DEVICE_LOCATION_NAME = "Current location"; + export default function TrigsV2() { const [searchParams, setSearchParams] = useSearchParams(); const { isAuthenticated } = useAuth0(); @@ -113,18 +116,21 @@ export default function TrigsV2() { // Filter State // ========================================================================== - // Location - const [centerLat, setCenterLat] = useState(() => { + // Location. Only a place the user picked is kept in the URL; otherwise the + // page uses the device's location afresh on each visit (falling back to + // Buxton), so refreshes follow the device and shared links don't carry the + // sharer's whereabouts. Older links saved "Current location" coordinates - + // those are ignored in favour of the device. + const [urlLocation] = useState(() => { const lat = parseFloat(searchParams.get("lat") || ""); - return lat || null; - }); - const [centerLon, setCenterLon] = useState(() => { const lon = parseFloat(searchParams.get("lon") || ""); - return lon || null; + const name = searchParams.get("location") || ""; + return lat && lon && name !== DEVICE_LOCATION_NAME ? { lat, lon, name } : null; }); - const [locationName, setLocationName] = useState( - () => searchParams.get("location") || "" - ); + const [centerLat, setCenterLat] = useState(urlLocation?.lat ?? null); + const [centerLon, setCenterLon] = useState(urlLocation?.lon ?? null); + const [locationName, setLocationName] = useState(urlLocation?.name ?? ""); + const [locationChosen, setLocationChosen] = useState(urlLocation !== null); // Categories (status IDs: 10=Pillar, 20=FBM, etc.) const [selectedCategories, setSelectedCategories] = useState(() => @@ -263,13 +269,16 @@ export default function TrigsV2() { (position) => { setCenterLat(position.coords.latitude); setCenterLon(position.coords.longitude); - setLocationName("Current location"); + setLocationName(DEVICE_LOCATION_NAME); }, + // Blocked, or no fix in time () => { setCenterLat(DEFAULT_LAT); setCenterLon(DEFAULT_LON); setLocationName(DEFAULT_LOCATION_NAME); - } + }, + // A fix from the last few minutes is fine; don't hang on a slow one + { maximumAge: 5 * 60 * 1000, timeout: 15 * 1000 } ); }, [centerLat]); @@ -282,6 +291,7 @@ export default function TrigsV2() { setCenterLat(lat); setCenterLon(lon); setLocationName(name); + setLocationChosen(name !== DEVICE_LOCATION_NAME); }, [] ); @@ -380,14 +390,10 @@ export default function TrigsV2() { const params = new URLSearchParams(); - // Location - if (centerLat !== null) { + // Location, only if the user picked it + if (locationChosen && centerLat !== null && centerLon !== null) { params.set("lat", centerLat.toFixed(5)); - } - if (centerLon !== null) { params.set("lon", centerLon.toFixed(5)); - } - if (locationName) { params.set("location", locationName); } @@ -438,7 +444,7 @@ export default function TrigsV2() { // Update URL without triggering navigation setSearchParams(params, { replace: true }); }, [ - filtersReady, centerLat, centerLon, locationName, maxKm, sortKey, sortDirection, + filtersReady, locationChosen, centerLat, centerLon, locationName, maxKm, sortKey, sortDirection, selectedCategories, selectedTypes, selectedConditions, selectedHistoricUse, selectedCurrentUse, selectedAreaIds, allTypeCodes, allConditionCodes, allHistoricUseValues, allCurrentUseValues, logUser, view, @@ -804,6 +810,7 @@ export default function TrigsV2() { filterParams={buildTrigFilterParams(filterOptions)} order={orderParam} logUserName={logUser?.name} + variant="subtle" /> )} @@ -818,6 +825,11 @@ export default function TrigsV2() { error={mapError} truncated={mapPoints?.truncated ?? false} showListActions={showListActions} + location={ + centerLat !== null && centerLon !== null + ? { lat: centerLat, lon: centerLon, name: locationName } + : undefined + } /> )} From 9437337fd72deb2fa6d897f066b87aa55b8ebc5d Mon Sep 17 00:00:00 2001 From: Ian Date: Fri, 25 Sep 2026 08:12:48 +0100 Subject: [PATCH 2/2] forum: put Auth0-created users in Newly Registered Users user_add() defaults user_new to 0 and only adds users to the NEWLY_REGISTERED group when the caller sets it; ucp_register does so explicitly but the Auth0 extension did not. As a result every forum sign-up bypassed the new-member post limit and the moderation queue that spam protection relies on. Pass user_new based on new_member_post_limit in both user-creation paths. Co-Authored-By: Claude Opus 5.5 --- forum/ext/teasel/auth0/event/subscriber.php | 5 ++++- forum/ext/teasel/auth0/service/provider.php | 3 +++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/forum/ext/teasel/auth0/event/subscriber.php b/forum/ext/teasel/auth0/event/subscriber.php index edd8f4a6..ab658877 100644 --- a/forum/ext/teasel/auth0/event/subscriber.php +++ b/forum/ext/teasel/auth0/event/subscriber.php @@ -171,7 +171,7 @@ protected function ensure_oauth_mapping($event) if ($user_id === 0) { // Create a new phpBB user using nickname as username - global $phpbb_root_path, $phpEx; + global $phpbb_root_path, $phpEx, $config; if (!function_exists('user_add')) { include_once($phpbb_root_path.'includes/functions_user.'.$phpEx); } @@ -202,6 +202,9 @@ protected function ensure_oauth_mapping($event) 'user_actkey' => '', // No activation needed 'user_inactive_reason' => 0, 'user_inactive_time' => 0, + // user_add() defaults this to 0; ucp_register sets it explicitly, so must we, + // otherwise new users skip the Newly Registered Users group (post moderation) + 'user_new' => ($config['new_member_post_limit']) ? 1 : 0, ]; $newId = user_add($userData, false); // false = suppress validation error array diff --git a/forum/ext/teasel/auth0/service/provider.php b/forum/ext/teasel/auth0/service/provider.php index d9e69573..8e7466f6 100644 --- a/forum/ext/teasel/auth0/service/provider.php +++ b/forum/ext/teasel/auth0/service/provider.php @@ -304,6 +304,9 @@ protected function ensure_oauth_mapping() 'user_actkey' => '', // No activation needed 'user_inactive_reason' => 0, 'user_inactive_time' => 0, + // user_add() defaults this to 0; ucp_register sets it explicitly, so must we, + // otherwise new users skip the Newly Registered Users group (post moderation) + 'user_new' => ($this->config['new_member_post_limit']) ? 1 : 0, ]; $newId = user_add($userData, false); // false = suppress validation error array