0e4bc7b444
PocketID OIDC already auto-provisioned users keyed by pocketid_sub, and the data layer was already fully user-scoped. This adds the missing pieces for running real multi-user: - auth.py callback: link by email to an existing un-linked account (so the admin keeps their data when first signing in by passkey), collision-safe username generation, and request the `groups` scope. - Group gating: optional pocketid_allowed_group (admin-config or POCKETID_ALLOWED_GROUP env); users lacking the group are rejected at the callback and redirected to /login?auth_error=not_authorized. - New admin users API (app/api/users.py): list users, promote/demote admin (guards against demoting/locking out the last admin or yourself), and delete a user with ordered bulk deletes of all their data + on-disk files. - ProfilePage: allowed-group field; LoginPage: rejected-login message; Layout: admin-only Users nav; new UsersPage. Resync milevault_export to current source (it had drifted many features behind — missing garmin_sync, npm-ci Dockerfile and @polyline-codec that broke its own CI) and add POCKETID_ALLOWED_GROUP to .env.example. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
133 lines
4.8 KiB
React
133 lines
4.8 KiB
React
import { useEffect, useRef } from 'react'
|
|
import L from 'leaflet'
|
|
import { sportColor } from '../../utils/format'
|
|
|
|
delete L.Icon.Default.prototype._getIconUrl
|
|
L.Icon.Default.mergeOptions({
|
|
iconUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon.png',
|
|
iconRetinaUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-icon-2x.png',
|
|
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
|
|
})
|
|
|
|
const TILE_LAYERS = {
|
|
dark: {
|
|
url: 'https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png',
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
|
},
|
|
street: {
|
|
url: 'https://{s}.basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}{r}.png',
|
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OSM</a> © <a href="https://carto.com/">CARTO</a>',
|
|
},
|
|
satellite: {
|
|
url: 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}',
|
|
attribution: '© <a href="https://www.esri.com/">Esri</a>',
|
|
},
|
|
}
|
|
|
|
function decodePolyline(encoded) {
|
|
const coords = []
|
|
let index = 0, lat = 0, lng = 0
|
|
while (index < encoded.length) {
|
|
let b, shift = 0, result = 0
|
|
do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
|
|
lat += (result & 1) ? ~(result >> 1) : result >> 1
|
|
shift = 0; result = 0
|
|
do { b = encoded.charCodeAt(index++) - 63; result |= (b & 0x1f) << shift; shift += 5 } while (b >= 0x20)
|
|
lng += (result & 1) ? ~(result >> 1) : result >> 1
|
|
coords.push([lat / 1e5, lng / 1e5])
|
|
}
|
|
return coords
|
|
}
|
|
|
|
function drawRoute(map, polyline, sportType, trackRef) {
|
|
if (trackRef.current) {
|
|
trackRef.current.remove()
|
|
trackRef.current = null
|
|
}
|
|
if (!polyline) return
|
|
|
|
const coords = decodePolyline(polyline)
|
|
if (!coords.length) return
|
|
|
|
trackRef.current = L.polyline(coords, {
|
|
color: sportColor(sportType),
|
|
weight: 3,
|
|
opacity: 0.9,
|
|
}).addTo(map)
|
|
|
|
map.fitBounds(trackRef.current.getBounds(), { padding: [20, 20] })
|
|
|
|
const dot = (color) => L.divIcon({
|
|
html: `<div style="width:12px;height:12px;background:${color};border:2px solid white;border-radius:50%"></div>`,
|
|
iconSize: [12, 12], iconAnchor: [6, 6], className: '',
|
|
})
|
|
L.marker(coords[0], { icon: dot('#22c55e') }).addTo(map)
|
|
L.marker(coords[coords.length - 1], { icon: dot('#ef4444') }).addTo(map)
|
|
}
|
|
|
|
export default function ActivityMap({ polyline, dataPoints, hoveredDistance, sportType, mapType = 'dark' }) {
|
|
const mapRef = useRef(null)
|
|
const mapInstanceRef = useRef(null)
|
|
const markerRef = useRef(null)
|
|
const trackRef = useRef(null)
|
|
const tileLayerRef = useRef(null)
|
|
const polylineRef = useRef(polyline)
|
|
const sportTypeRef = useRef(sportType)
|
|
|
|
useEffect(() => { polylineRef.current = polyline }, [polyline])
|
|
useEffect(() => { sportTypeRef.current = sportType }, [sportType])
|
|
|
|
useEffect(() => {
|
|
if (!mapRef.current || mapInstanceRef.current) return
|
|
|
|
mapInstanceRef.current = L.map(mapRef.current, {
|
|
zoomControl: true,
|
|
attributionControl: true,
|
|
})
|
|
|
|
const tile = TILE_LAYERS.dark
|
|
tileLayerRef.current = L.tileLayer(tile.url, {
|
|
attribution: tile.attribution,
|
|
maxZoom: 19,
|
|
}).addTo(mapInstanceRef.current)
|
|
|
|
return () => {
|
|
mapInstanceRef.current?.remove()
|
|
mapInstanceRef.current = null
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
if (!mapInstanceRef.current) return
|
|
const tile = TILE_LAYERS[mapType] || TILE_LAYERS.dark
|
|
if (tileLayerRef.current) tileLayerRef.current.remove()
|
|
tileLayerRef.current = L.tileLayer(tile.url, {
|
|
attribution: tile.attribution,
|
|
maxZoom: 19,
|
|
}).addTo(mapInstanceRef.current)
|
|
drawRoute(mapInstanceRef.current, polylineRef.current, sportTypeRef.current, trackRef)
|
|
}, [mapType])
|
|
|
|
useEffect(() => {
|
|
if (!mapInstanceRef.current) return
|
|
drawRoute(mapInstanceRef.current, polyline, sportType, trackRef)
|
|
}, [polyline, sportType])
|
|
|
|
useEffect(() => {
|
|
if (!mapInstanceRef.current || !dataPoints || hoveredDistance == null) return
|
|
const point = dataPoints.find(p => p.distance_m >= hoveredDistance)
|
|
if (!point?.latitude || !point?.longitude) return
|
|
if (markerRef.current) {
|
|
markerRef.current.setLatLng([point.latitude, point.longitude])
|
|
} else {
|
|
const icon = L.divIcon({
|
|
html: '<div style="width:14px;height:14px;background:#fff;border:3px solid #3b82f6;border-radius:50%;box-shadow:0 0 6px rgba(59,130,246,0.8)"></div>',
|
|
iconSize: [14, 14], iconAnchor: [7, 7], className: '',
|
|
})
|
|
markerRef.current = L.marker([point.latitude, point.longitude], { icon })
|
|
.addTo(mapInstanceRef.current)
|
|
}
|
|
}, [hoveredDistance, dataPoints])
|
|
|
|
return <div ref={mapRef} style={{ height: '100%', width: '100%', background: '#1a1a2e' }} />
|
|
} |