Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
27b4a44
Added precise location sharing option
anurag2787 Nov 15, 2025
7d4efc6
update
anurag2787 Nov 15, 2025
47e56a2
update warning
anurag2787 Nov 15, 2025
4d2aa62
Merge branch 'main' into location-sharing
anurag2787 Nov 16, 2025
cc99765
Merge branch 'main' of github.com:anurag2787/Nest into location-sharing
anurag2787 Nov 18, 2025
cfc8424
Fixed review
anurag2787 Nov 18, 2025
927f614
Merge branch 'main' into location-sharing
anurag2787 Nov 18, 2025
bd0111b
Merge branch 'location-sharing' of github.com:anurag2787/Nest into lo…
anurag2787 Nov 18, 2025
81d3bd7
sonarqube warning fixed
anurag2787 Nov 18, 2025
b613afe
Update code
arkid15r Nov 19, 2025
a5038f3
Update code
arkid15r Nov 19, 2025
180d1ed
Update code
arkid15r Nov 19, 2025
02ebf66
Update code
arkid15r Nov 19, 2025
a520752
Merge branch 'main' into pr/anurag2787/2644
arkid15r Nov 19, 2025
01dae6d
Merge branch 'main' into location-sharing
arkid15r Nov 19, 2025
d27230b
fixed sonarqube warning:
anurag2787 Nov 19, 2025
2137935
Merge branch 'location-sharing' of github.com:anurag2787/Nest into lo…
anurag2787 Nov 19, 2025
1ea9a97
Merge branch 'main' into location-sharing
anurag2787 Nov 19, 2025
6776976
Merge branch 'main' into location-sharing
kasya Dec 9, 2025
bb06775
Added red point for user location and updated ui for location-sharing
Dec 9, 2025
eba33fa
Merge branch 'main' into location-sharing
anurag2787 Dec 9, 2025
8342e2d
fixed sonarqube warning
Dec 9, 2025
f75ab07
Merge branch 'location-sharing' of https://github.com/anurag2787/Nest…
Dec 9, 2025
deaef06
fixed coderabbitai review
Dec 9, 2025
3ce07f1
Update pin and button styling
kasya Dec 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions frontend/src/app/chapters/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,7 @@ const ChaptersPage = () => {
<ChapterMapWrapper
geoLocData={searchQuery ? chapters : geoLocData}
showLocal={true}
showLocationSharing={true}
style={{
height: '400px',
width: '100%',
Expand Down
81 changes: 77 additions & 4 deletions frontend/src/components/ChapterMap.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
'use client'
import { faLocationDot } from '@fortawesome/free-solid-svg-icons'
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'
import { Button } from '@heroui/button'
import { Tooltip } from '@heroui/tooltip'
import L, { MarkerClusterGroup } from 'leaflet'
import React, { useEffect, useRef, useState } from 'react'
import type { Chapter } from 'types/chapter'
import type { UserLocation } from 'utils/geolocationUtils'
import 'leaflet.markercluster'
import 'leaflet/dist/leaflet.css'
import 'leaflet.markercluster/dist/MarkerCluster.css'
Expand All @@ -12,10 +17,14 @@ const ChapterMap = ({
geoLocData,
showLocal,
style,
userLocation,
onShareLocation,
}: {
geoLocData: Chapter[]
showLocal: boolean
style: React.CSSProperties
userLocation?: UserLocation | null
onShareLocation?: () => void
}) => {
const mapRef = useRef<L.Map | null>(null)
const markerClusterRef = useRef<MarkerClusterGroup | null>(null)
Expand Down Expand Up @@ -103,7 +112,49 @@ const ChapterMap = ({

markerClusterGroup.addLayers(markers)

if (showLocal && validGeoLocData.length > 0) {
// Add user location marker if available
if (userLocation && map) {
const iconHtml =
'<img src="/img/marker-icon.png" style="filter: hue-rotate(150deg) saturate(1.5) brightness(0.9); width: 25px; height: 41px;" alt="User location" />'
const userMarkerIcon = L.divIcon({
html: iconHtml,
className: 'user-location-marker',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
})

const userMarker = L.marker([userLocation.latitude, userLocation.longitude], {
icon: userMarkerIcon,
})
const userPopup = L.popup()
const userPopupContent = document.createElement('div')
userPopupContent.textContent = 'Your Location'
userPopup.setContent(userPopupContent)
userMarker.bindPopup(userPopup)
userMarker.addTo(map)
}

if (userLocation && validGeoLocData.length > 0) {
const maxNearestChapters = 5
const localChapters = validGeoLocData.slice(0, maxNearestChapters)
const localBounds = L.latLngBounds(
localChapters.map((chapter) => [
chapter._geoloc?.lat ?? chapter.geoLocation?.lat,
chapter._geoloc?.lng ?? chapter.geoLocation?.lng,
])
)
const maxZoom = 12
const nearestChapter = validGeoLocData[0]
map.setView(
[
nearestChapter._geoloc?.lat ?? nearestChapter.geoLocation?.lat,
nearestChapter._geoloc?.lng ?? nearestChapter.geoLocation?.lng,
],
maxZoom
)
map.fitBounds(localBounds, { maxZoom: maxZoom })
} else if (showLocal && validGeoLocData.length > 0) {
const maxNearestChapters = 5
const localChapters = validGeoLocData.slice(0, maxNearestChapters - 1)
const localBounds = L.latLngBounds(
Expand All @@ -123,7 +174,7 @@ const ChapterMap = ({
)
map.fitBounds(localBounds, { maxZoom: maxZoom })
}
}, [geoLocData, showLocal])
}, [geoLocData, showLocal, userLocation])

return (
<div className="relative" style={style}>
Expand All @@ -132,7 +183,7 @@ const ChapterMap = ({
<button
type="button"
tabIndex={0}
className="absolute inset-0 z-[1000] flex cursor-pointer items-center justify-center rounded-[inherit] bg-black/10"
className="pointer-events-none absolute inset-0 z-[500] flex cursor-pointer items-center justify-center rounded-[inherit] bg-black/10"
onClick={() => {
mapRef.current?.scrollWheelZoom.enable()
setIsMapActive(true)
Expand All @@ -146,11 +197,33 @@ const ChapterMap = ({
}}
aria-label="Click to interact with map"
>
<p className="rounded-md bg-white/90 px-5 py-3 text-sm font-medium text-gray-700 shadow-lg dark:bg-gray-700 dark:text-white">
<p className="pointer-events-auto rounded-md bg-white/90 px-5 py-3 text-sm font-medium text-gray-700 shadow-lg dark:bg-gray-700 dark:text-white">
Click to interact with map
</p>
</button>
)}
<div className="absolute top-20 left-3 z-[999] w-fit">
{onShareLocation && (
<Tooltip
showArrow
content={
userLocation ? 'Reset location filter' : 'Share your location to find nearby chapters'
}
placement="bottom-start"
>
<Button
isIconOnly
className="h-[30px] w-[30px] min-w-[30px] rounded-xs bg-white text-gray-700 shadow-lg outline-2 outline-gray-400 hover:bg-gray-100 dark:outline-gray-700"
onPress={onShareLocation}
aria-label={
userLocation ? 'Reset location filter' : 'Share location to find nearby chapters'
}
>
<FontAwesomeIcon icon={faLocationDot} size="sm" />
</Button>
</Tooltip>
)}
</div>
</div>
)
}
Expand Down
57 changes: 53 additions & 4 deletions frontend/src/components/ChapterMapWrapper.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,64 @@
import dynamic from 'next/dynamic'
import React from 'react'
import React, { useState } from 'react'
import type { Chapter } from 'types/chapter'
import {
getUserLocationFromBrowser,
sortChaptersByDistance,
type UserLocation,
} from 'utils/geolocationUtils'

const ChapterMap = dynamic(() => import('./ChapterMap'), { ssr: false })

const ChapterMapWrapper = (props: {
interface ChapterMapWrapperProps {
geoLocData: Chapter[]
showLocal: boolean
style: React.CSSProperties
}) => {
return <ChapterMap {...props} />
showLocationSharing?: boolean
}

const ChapterMapWrapper: React.FC<ChapterMapWrapperProps> = (props) => {
const [userLocation, setUserLocation] = useState<UserLocation | null>(null)
const [sortedData, setSortedData] = useState<Chapter[] | null>(null)

const enableLocationSharing = props.showLocationSharing === true

if (!enableLocationSharing) {
return <ChapterMap {...props} />
}

const handleShareLocation = async () => {
if (userLocation) {
setUserLocation(null)
setSortedData(null)
return
}

try {
const location = await getUserLocationFromBrowser()

if (location) {
setUserLocation(location)
const sorted = sortChaptersByDistance(props.geoLocData, location)
setSortedData(sorted.map(({ _distance, ...chapter }) => chapter as unknown as Chapter))
}
} catch (error) {
// eslint-disable-next-line no-console
console.error('Error detecting location:', error)
}
}

const mapData = sortedData ?? props.geoLocData

return (
<div className="space-y-4">
<ChapterMap
{...props}
geoLocData={mapData}
userLocation={userLocation}
onShareLocation={handleShareLocation}
/>
</div>
)
}

export default ChapterMapWrapper
99 changes: 99 additions & 0 deletions frontend/src/utils/geolocationUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
export interface UserLocation {
latitude: number
longitude: number
city?: string
country?: string
}

interface ChapterCoordinates {
lat: number | null
lng: number | null
}

export const calculateDistance = (
lat1: number,
lon1: number,
lat2: number,
lon2: number
): number => {
const R = 6371
const dLat = ((lat2 - lat1) * Math.PI) / 180
const dLon = ((lon2 - lon1) * Math.PI) / 180

const a =
Math.sin(dLat / 2) ** 2 +
Math.cos((lat1 * Math.PI) / 180) * Math.cos((lat2 * Math.PI) / 180) * Math.sin(dLon / 2) ** 2

const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a))
return R * c
}

export const getUserLocationFromBrowser = (): Promise<UserLocation | null> => {
return new Promise((resolve) => {
if (!navigator.geolocation) {
// eslint-disable-next-line no-console
console.warn('Geolocation API not supported')
resolve(null)
return
}

/* Geolocation permission is required for the "Find chapters near you" feature.
The user must explicitly opt in by clicking a button. The location data never
leaves the client and is not sent to the backend. It is used only to calculate
distances between the user and nearby chapters.
*/
navigator.geolocation.getCurrentPosition(
(position) => {
resolve({
latitude: position.coords.latitude,
longitude: position.coords.longitude,
})
},
(error) => {
// eslint-disable-next-line no-console
console.warn('Browser geolocation error:', error.message)
resolve(null)
},
{
enableHighAccuracy: false,
timeout: 10000,
maximumAge: 0,
}
)
})
}

const extractChapterCoordinates = (chapter: Record<string, unknown>): ChapterCoordinates => {
const lat =
(chapter._geoloc as Record<string, unknown>)?.lat ??
(chapter.geoLocation as Record<string, unknown>)?.lat ??
null

const lng =
(chapter._geoloc as Record<string, unknown>)?.lng ??
(chapter.geoLocation as Record<string, unknown>)?.lng ??
null

return { lat: lat as number | null, lng: lng as number | null }
}

/**
* Sort chapters by distance from user
*/
export const sortChaptersByDistance = (
chapters: Record<string, unknown>[],
userLocation: UserLocation
): Array<Record<string, unknown> & { distance: number }> => {
return chapters
.map((chapter) => {
const { lat, lng } = extractChapterCoordinates(chapter)

if (typeof lat !== 'number' || typeof lng !== 'number') return null

const distance = calculateDistance(userLocation.latitude, userLocation.longitude, lat, lng)

return { ...chapter, distance }
})
.filter((item): item is Record<string, unknown> & { distance: number } => item !== null)
.sort((a, b) => a.distance - b.distance)
}