-
-
Notifications
You must be signed in to change notification settings - Fork 288
Add precise location sharing option for chapter map #2644
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
27b4a44
7d4efc6
47e56a2
4d2aa62
cc99765
cfc8424
927f614
bd0111b
81d3bd7
b613afe
a5038f3
180d1ed
02ebf66
a520752
01dae6d
d27230b
2137935
1ea9a97
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,15 +1,105 @@ | ||
| import { faLocationDot } from '@fortawesome/free-solid-svg-icons' | ||
| import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' | ||
| import { Button } from '@heroui/button' | ||
| 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 [isLoadingLocation, setIsLoadingLocation] = useState(false) | ||
| 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 | ||
| } | ||
|
|
||
| setIsLoadingLocation(true) | ||
|
|
||
| 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) | ||
| } finally { | ||
| setIsLoadingLocation(false) | ||
| } | ||
| } | ||
|
|
||
| const mapData = sortedData ?? props.geoLocData | ||
|
|
||
| return ( | ||
| <div className="space-y-4"> | ||
| <div className="mb-4 flex items-center gap-3 rounded-lg bg-gray-100 p-4 shadow-md dark:bg-gray-800"> | ||
| <Button | ||
| isIconOnly | ||
| className="bg-blue-500 text-white hover:bg-blue-600" | ||
| onClick={handleShareLocation} | ||
| isLoading={isLoadingLocation} | ||
| disabled={isLoadingLocation} | ||
| aria-label={ | ||
| userLocation ? 'Reset location filter' : 'Share location to find nearby chapters' | ||
| } | ||
| title={userLocation ? 'Reset location filter' : 'Share location to find nearby chapters'} | ||
| > | ||
| <FontAwesomeIcon icon={faLocationDot} size="lg" /> | ||
| </Button> | ||
|
|
||
| <div className="text-sm text-gray-700 dark:text-gray-300"> | ||
| {userLocation ? ( | ||
| <> | ||
| <div className="font-semibold text-blue-600 dark:text-blue-400"> | ||
| 📍 Showing chapters near you | ||
| </div> | ||
| <div className="text-xs text-gray-600 dark:text-gray-400"> | ||
| Location: {userLocation.latitude.toFixed(2)}, {userLocation.longitude.toFixed(2)} | ||
| </div> | ||
| </> | ||
| ) : ( | ||
| <> | ||
| <div className="font-semibold text-gray-800 dark:text-gray-200"> | ||
| Find chapters near you | ||
| </div> | ||
| <div className="text-xs text-gray-600 dark:text-gray-400"> | ||
| Click the blue button to use your current location | ||
| </div> | ||
| </> | ||
| )} | ||
| </div> | ||
anurag2787 marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| </div> | ||
|
|
||
| <ChapterMap {...props} geoLocData={mapData} userLocation={userLocation} /> | ||
| </div> | ||
| ) | ||
| } | ||
|
|
||
| export default ChapterMapWrapper | ||
| 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 | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+1
to
+6
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove or document unused optional fields. The Apply this diff to remove the unused fields: export interface UserLocation {
latitude: number
longitude: number
- city?: string
- country?: string
}Alternatively, if these are planned for future use, add a comment: export interface UserLocation {
latitude: number
longitude: number
+ // Reserved for future enhancement (reverse geocoding)
city?: string
country?: string
}📝 Committable suggestion
Suggested change
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||||
| 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 !== null) | ||||||||||||||||||||||||||||||||||||||||||||||||
| .sort((a, b) => a!.distance - b!.distance) | ||||||||||||||||||||||||||||||||||||||||||||||||
| } | ||||||||||||||||||||||||||||||||||||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.