8 changed files with 644 additions and 0 deletions
@ -0,0 +1,80 @@ |
|||
import { exec } from 'child_process'; |
|||
import { promisify } from 'util'; |
|||
import http from 'http'; |
|||
|
|||
const execAsync = promisify(exec); |
|||
|
|||
let lastPosition = null; |
|||
let isEnabled = true; // Can be controlled via /control endpoint
|
|||
|
|||
async function getAndroidGPS() { |
|||
if (!isEnabled) { |
|||
lastPosition = null; |
|||
return null; |
|||
} |
|||
|
|||
try { |
|||
const { stdout } = await execAsync('adb shell dumpsys location'); |
|||
const match = stdout.match(/last location=Location\[\w+\s+([\-\d.]+),([\-\d.]+)\s+hAcc=([\d.]+)/i); |
|||
|
|||
if (match) { |
|||
lastPosition = { |
|||
coords: { |
|||
latitude: parseFloat(match[1]), |
|||
longitude: parseFloat(match[2]), |
|||
accuracy: parseFloat(match[3]) || 10, |
|||
altitude: null, |
|||
altitudeAccuracy: null, |
|||
heading: null, |
|||
speed: null |
|||
}, |
|||
timestamp: Date.now() |
|||
}; |
|||
console.log('[GPS Bridge] GPS data:', lastPosition.coords.latitude, lastPosition.coords.longitude); |
|||
} |
|||
} catch (error) { |
|||
console.error('[GPS Bridge] Error reading GPS:', error.message); |
|||
lastPosition = null; // Clear cache when device disconnected
|
|||
} |
|||
return lastPosition; |
|||
} |
|||
|
|||
const server = http.createServer(async (req, res) => { |
|||
res.setHeader('Access-Control-Allow-Origin', '*'); |
|||
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS'); |
|||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); |
|||
|
|||
if (req.method === 'OPTIONS') { |
|||
res.writeHead(200); |
|||
res.end(); |
|||
return; |
|||
} |
|||
|
|||
if (req.url === '/gps') { |
|||
const position = await getAndroidGPS(); |
|||
res.writeHead(200, { 'Content-Type': 'application/json' }); |
|||
res.end(JSON.stringify(position || { coords: null })); |
|||
} else if (req.url.startsWith('/control')) { |
|||
const url = new URL(req.url, `http://${req.headers.host}`); |
|||
const enabled = url.searchParams.get('enabled'); |
|||
if (enabled !== null) { |
|||
isEnabled = enabled === 'true'; |
|||
console.log(`[GPS Bridge] ${isEnabled ? 'Enabled' : 'Disabled'}`); |
|||
if (!isEnabled) { |
|||
lastPosition = null; |
|||
} |
|||
} |
|||
res.writeHead(200, { 'Content-Type': 'application/json' }); |
|||
res.end(JSON.stringify({ enabled: isEnabled })); |
|||
} else { |
|||
res.writeHead(404); |
|||
res.end(); |
|||
} |
|||
}); |
|||
|
|||
setInterval(getAndroidGPS, 30000); // Poll every 30 seconds
|
|||
|
|||
server.listen(8080, () => { |
|||
console.log('[GPS Bridge] Running on http://localhost:8080'); |
|||
console.log('[GPS Bridge] Endpoint: http://localhost:8080/gps'); |
|||
}); |
|||
@ -0,0 +1,90 @@ |
|||
import { useState, useEffect } from 'react'; |
|||
import { gpsService } from '../services/gpsService'; |
|||
|
|||
export interface GPSPosition { |
|||
latitude: number; |
|||
longitude: number; |
|||
accuracy: number; |
|||
timestamp: number; |
|||
} |
|||
|
|||
export function useGPS() { |
|||
const [position, setPosition] = useState<GPSPosition | null>(null); |
|||
const [error, setError] = useState<string | null>(null); |
|||
const [loading, setLoading] = useState(true); |
|||
|
|||
useEffect(() => { |
|||
let mounted = true; |
|||
|
|||
const updatePosition = () => { |
|||
const lastKnown = gpsService.getLastKnownPosition(); |
|||
if (lastKnown && mounted) { |
|||
setPosition({ |
|||
latitude: lastKnown.coords.latitude, |
|||
longitude: lastKnown.coords.longitude, |
|||
accuracy: lastKnown.coords.accuracy, |
|||
timestamp: lastKnown.timestamp |
|||
}); |
|||
setError(null); |
|||
setLoading(false); |
|||
} |
|||
}; |
|||
|
|||
// Check for position immediately
|
|||
updatePosition(); |
|||
|
|||
// Try to get current position
|
|||
gpsService.getCurrentPosition() |
|||
.then((pos) => { |
|||
if (mounted) { |
|||
setPosition({ |
|||
latitude: pos.coords.latitude, |
|||
longitude: pos.coords.longitude, |
|||
accuracy: pos.coords.accuracy, |
|||
timestamp: pos.timestamp |
|||
}); |
|||
setError(null); |
|||
setLoading(false); |
|||
} |
|||
}) |
|||
.catch((err) => { |
|||
if (mounted) { |
|||
setError(err.message); |
|||
setLoading(false); |
|||
} |
|||
}); |
|||
|
|||
// Update position periodically
|
|||
const interval = setInterval(updatePosition, 30000); |
|||
|
|||
return () => { |
|||
mounted = false; |
|||
clearInterval(interval); |
|||
}; |
|||
}, []); |
|||
|
|||
const refreshPosition = async () => { |
|||
setLoading(true); |
|||
try { |
|||
const pos = await gpsService.getCurrentPosition(); |
|||
setPosition({ |
|||
latitude: pos.coords.latitude, |
|||
longitude: pos.coords.longitude, |
|||
accuracy: pos.coords.accuracy, |
|||
timestamp: pos.timestamp |
|||
}); |
|||
setError(null); |
|||
} catch (err) { |
|||
setError(err instanceof Error ? err.message : 'GPS error'); |
|||
} finally { |
|||
setLoading(false); |
|||
} |
|||
}; |
|||
|
|||
return { |
|||
position, |
|||
error, |
|||
loading, |
|||
refreshPosition |
|||
}; |
|||
} |
|||
@ -0,0 +1,16 @@ |
|||
const isDev = import.meta.env.DEV; |
|||
|
|||
export const logger = { |
|||
debug: (...args: any[]) => { |
|||
if (isDev) console.debug(...args); |
|||
}, |
|||
info: (...args: any[]) => { |
|||
if (isDev) console.info(...args); |
|||
}, |
|||
warn: (...args: any[]) => { |
|||
console.warn(...args); |
|||
}, |
|||
error: (...args: any[]) => { |
|||
console.error(...args); |
|||
} |
|||
}; |
|||
@ -0,0 +1,195 @@ |
|||
import { logger } from "../logger.ts"; |
|||
|
|||
// GPS Service for automatic tethered device detection
|
|||
export class GPSService { |
|||
private position: GeolocationPosition | null = null; |
|||
private watchId: number | null = null; |
|||
private fallbackSources: Array<() => Promise<GeolocationPosition | null>> = []; |
|||
private isDev = import.meta.env.DEV; |
|||
|
|||
constructor() { |
|||
if (this.isDev) { |
|||
this.setupFallbackSources(); |
|||
} |
|||
this.startGPSWatch(); |
|||
} |
|||
|
|||
private setupFallbackSources() { |
|||
// Only setup fallback sources in development
|
|||
if (!this.isDev) return; |
|||
|
|||
// ADB GPS Bridge (Android devices)
|
|||
this.fallbackSources.push(async () => { |
|||
// Check if Android GPS is enabled
|
|||
if (localStorage.getItem('gps-android') === 'false') return null; |
|||
|
|||
try { |
|||
const response = await fetch('http://localhost:8080/gps', { |
|||
signal: AbortSignal.timeout(2000) |
|||
}); |
|||
const data = await response.json(); |
|||
if (data.coords) { |
|||
logger.debug('GPS: Using ADB bridge'); |
|||
return { |
|||
coords: { |
|||
latitude: data.coords.latitude, |
|||
longitude: data.coords.longitude, |
|||
accuracy: data.coords.accuracy || 10, |
|||
altitude: null, |
|||
altitudeAccuracy: null, |
|||
heading: null, |
|||
speed: null |
|||
}, |
|||
timestamp: Date.now() |
|||
} as GeolocationPosition; |
|||
} |
|||
} catch { |
|||
// GPS bridge not available - silent fail
|
|||
} |
|||
return null; |
|||
}); |
|||
|
|||
// iOS/iPad via libimobiledevice (if available)
|
|||
this.fallbackSources.push(async () => { |
|||
// Check if iOS GPS is enabled
|
|||
if (localStorage.getItem('gps-ios') === 'false') return null; |
|||
|
|||
try { |
|||
const response = await fetch('http://localhost:8081/ios-gps', { |
|||
signal: AbortSignal.timeout(2000) |
|||
}); |
|||
const data = await response.json(); |
|||
if (data.coords) { |
|||
logger.debug('GPS: Using iOS bridge'); |
|||
return { |
|||
coords: { |
|||
latitude: data.coords.latitude, |
|||
longitude: data.coords.longitude, |
|||
accuracy: data.coords.accuracy || 10, |
|||
altitude: null, |
|||
altitudeAccuracy: null, |
|||
heading: null, |
|||
speed: null |
|||
}, |
|||
timestamp: Date.now() |
|||
} as GeolocationPosition; |
|||
} |
|||
} catch { |
|||
// iOS GPS not available - silent fail
|
|||
} |
|||
return null; |
|||
}); |
|||
} |
|||
|
|||
private async tryFallbackSources(): Promise<GeolocationPosition | null> { |
|||
for (const source of this.fallbackSources) { |
|||
const position = await source(); |
|||
if (position) { |
|||
|
|||
return position; |
|||
} |
|||
} |
|||
return null; |
|||
} |
|||
|
|||
private startGPSWatch() { |
|||
// Try tethered GPS first (more accurate)
|
|||
this.tryFallbackLoop(); |
|||
|
|||
// Also start browser geolocation as backup
|
|||
if (navigator.geolocation) { |
|||
this.watchId = navigator.geolocation.watchPosition( |
|||
(position) => { |
|||
// Check if browser GPS is enabled
|
|||
if (localStorage.getItem('gps-browser') === 'false') return; |
|||
|
|||
// Only use browser GPS if no tethered GPS available
|
|||
if (!this.position) { |
|||
this.position = position; |
|||
logger.debug('GPS: Using browser geolocation'); |
|||
} |
|||
}, |
|||
() => { |
|||
logger.debug('GPS: Browser geolocation failed'); |
|||
}, |
|||
{ |
|||
enableHighAccuracy: true, |
|||
timeout: 10000, |
|||
maximumAge: 30000 |
|||
} |
|||
); |
|||
} |
|||
} |
|||
|
|||
private async tryFallbackLoop() { |
|||
// Check immediately
|
|||
const initialPosition = await this.tryFallbackSources(); |
|||
if (initialPosition) { |
|||
this.position = initialPosition; |
|||
} |
|||
|
|||
// Then check every 5 seconds
|
|||
setInterval(async () => { |
|||
const fallbackPosition = await this.tryFallbackSources(); |
|||
if (fallbackPosition) { |
|||
this.position = fallbackPosition; |
|||
} |
|||
}, 5000); |
|||
} |
|||
|
|||
public getCurrentPosition(): Promise<GeolocationPosition> { |
|||
return new Promise(async (resolve, reject) => { |
|||
if (this.position) { |
|||
resolve(this.position); |
|||
return; |
|||
} |
|||
|
|||
// Try browser geolocation
|
|||
if (navigator.geolocation) { |
|||
navigator.geolocation.getCurrentPosition( |
|||
resolve, |
|||
async (error) => { |
|||
// Try fallback sources
|
|||
const fallbackPosition = await this.tryFallbackSources(); |
|||
if (fallbackPosition) { |
|||
resolve(fallbackPosition); |
|||
} else { |
|||
reject(error); |
|||
} |
|||
}, |
|||
{ |
|||
enableHighAccuracy: true, |
|||
timeout: 10000, |
|||
maximumAge: 30000 |
|||
} |
|||
); |
|||
} else { |
|||
// Try fallback sources
|
|||
const fallbackPosition = await this.tryFallbackSources(); |
|||
if (fallbackPosition) { |
|||
resolve(fallbackPosition); |
|||
} else { |
|||
reject(new Error('No GPS sources available')); |
|||
} |
|||
} |
|||
}); |
|||
} |
|||
|
|||
public getLastKnownPosition(): GeolocationPosition | null { |
|||
return this.position; |
|||
} |
|||
|
|||
public async getTetheredPosition(): Promise<GeolocationPosition | null> { |
|||
// Only try tethered sources, not browser geolocation
|
|||
return await this.tryFallbackSources(); |
|||
} |
|||
|
|||
public destroy() { |
|||
if (this.watchId !== null) { |
|||
navigator.geolocation.clearWatch(this.watchId); |
|||
} |
|||
} |
|||
} |
|||
|
|||
// Global GPS service instance
|
|||
export const gpsService = new GPSService(); |
|||
@ -0,0 +1,251 @@ |
|||
import { useGPS } from '@core/hooks/useGPS'; |
|||
import { PageLayout } from '@components/PageLayout.tsx'; |
|||
import { Sidebar } from '@components/Sidebar.tsx'; |
|||
import { Switch } from '@components/UI/Switch.tsx'; |
|||
import { toast } from '@core/hooks/useToast'; |
|||
import { Satellite, Smartphone, Wifi, RefreshCw, Send } from 'lucide-react'; |
|||
import { useState, useEffect } from 'react'; |
|||
|
|||
export default function GPSPage() { |
|||
const { position, error, loading, refreshPosition } = useGPS(); |
|||
const [isRefreshing, setIsRefreshing] = useState(false); |
|||
const [isSending, setIsSending] = useState(false); |
|||
const [useBrowser, setUseBrowser] = useState(() => localStorage.getItem('gps-browser') !== 'false'); |
|||
const [useAndroid, setUseAndroid] = useState(() => localStorage.getItem('gps-android') === 'true'); |
|||
const [useIOS, setUseIOS] = useState(() => localStorage.getItem('gps-ios') !== 'false'); |
|||
const [browserConnected, setBrowserConnected] = useState(false); |
|||
const [androidConnected, setAndroidConnected] = useState(false); |
|||
const [iosConnected, setIOSConnected] = useState(false); |
|||
|
|||
// Save to localStorage when changed
|
|||
const handleBrowserToggle = (checked: boolean) => { |
|||
setUseBrowser(checked); |
|||
localStorage.setItem('gps-browser', String(checked)); |
|||
}; |
|||
|
|||
const handleAndroidToggle = (checked: boolean) => { |
|||
setUseAndroid(checked); |
|||
localStorage.setItem('gps-android', String(checked)); |
|||
}; |
|||
|
|||
const handleIOSToggle = (checked: boolean) => { |
|||
setUseIOS(checked); |
|||
localStorage.setItem('gps-ios', String(checked)); |
|||
}; |
|||
|
|||
// Check GPS source connectivity
|
|||
useEffect(() => { |
|||
const checkSources = async () => { |
|||
// Check browser GPS
|
|||
if (navigator.geolocation) { |
|||
setBrowserConnected(true); |
|||
} |
|||
|
|||
// Check Android GPS
|
|||
try { |
|||
const response = await fetch('http://localhost:8080/gps'); |
|||
const data = await response.json(); |
|||
setAndroidConnected(!!data.coords); |
|||
} catch { |
|||
setAndroidConnected(false); |
|||
} |
|||
|
|||
// Check iOS GPS
|
|||
try { |
|||
const response = await fetch('http://localhost:8081/ios-gps'); |
|||
const data = await response.json(); |
|||
setIOSConnected(!!data.coords); |
|||
} catch { |
|||
setIOSConnected(false); |
|||
} |
|||
}; |
|||
checkSources(); |
|||
const interval = setInterval(checkSources, 5000); |
|||
return () => clearInterval(interval); |
|||
}, []); |
|||
|
|||
const handleRefresh = async () => { |
|||
setIsRefreshing(true); |
|||
await refreshPosition(); |
|||
setIsRefreshing(false); |
|||
}; |
|||
|
|||
return ( |
|||
<PageLayout |
|||
label="GPS Status" |
|||
leftBar={<Sidebar />} |
|||
actions={[ |
|||
{ |
|||
key: 'refresh', |
|||
icon: RefreshCw, |
|||
iconClasses: isRefreshing ? 'animate-spin' : '', |
|||
onClick: handleRefresh, |
|||
disabled: isRefreshing, |
|||
label: 'Refresh', |
|||
className: 'bg-blue-600 text-white hover:bg-blue-700 disabled:opacity-50' |
|||
}, |
|||
|
|||
]} |
|||
> |
|||
<div className="p-6 max-w-4xl mx-auto overflow-y-auto"> |
|||
|
|||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6"> |
|||
{/* Current Position */} |
|||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6"> |
|||
<div className="flex items-center gap-3 mb-4"> |
|||
<Satellite className="w-6 h-6 text-blue-600" /> |
|||
<h2 className="text-lg font-semibold">Current Position</h2> |
|||
</div> |
|||
|
|||
{loading ? ( |
|||
<div className="text-gray-500">Loading GPS data...</div> |
|||
) : error ? ( |
|||
<div className="text-red-500">Error: {error}</div> |
|||
) : position ? ( |
|||
<div className="space-y-2"> |
|||
<div className="flex justify-between"> |
|||
<span className="text-gray-600 dark:text-gray-400">Latitude:</span> |
|||
<span className="font-mono">{position.latitude.toFixed(6)}°</span> |
|||
</div> |
|||
<div className="flex justify-between"> |
|||
<span className="text-gray-600 dark:text-gray-400">Longitude:</span> |
|||
<span className="font-mono">{position.longitude.toFixed(6)}°</span> |
|||
</div> |
|||
<div className="flex justify-between"> |
|||
<span className="text-gray-600 dark:text-gray-400">Accuracy:</span> |
|||
<span className="font-mono">{position.accuracy.toFixed(1)}m</span> |
|||
</div> |
|||
<div className="flex justify-between"> |
|||
<span className="text-gray-600 dark:text-gray-400">Last Update:</span> |
|||
<span className="text-sm">{new Date(position.timestamp).toLocaleTimeString()}</span> |
|||
</div> |
|||
</div> |
|||
) : ( |
|||
<div className="text-gray-500">No GPS data available</div> |
|||
)} |
|||
</div> |
|||
|
|||
{/* GPS Sources */} |
|||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6"> |
|||
<div className="flex items-center gap-3 mb-4"> |
|||
<Wifi className="w-6 h-6 text-green-600" /> |
|||
<h2 className="text-lg font-semibold">GPS Sources</h2> |
|||
</div> |
|||
|
|||
{!androidConnected && !iosConnected && ( |
|||
<div className="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg text-sm"> |
|||
<div className="font-medium mb-1">External GPS Bridge</div> |
|||
<div className="text-gray-600 dark:text-gray-400"> |
|||
To use GPS from a tethered device, run: |
|||
<code className="block mt-1 p-2 bg-gray-100 dark:bg-gray-800 rounded">node gps-bridge-service.js</code> |
|||
</div> |
|||
</div> |
|||
)} |
|||
|
|||
<div className="space-y-3"> |
|||
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"> |
|||
<div className="flex items-center gap-3"> |
|||
<div className={`w-3 h-3 rounded-full ${browserConnected && useBrowser ? 'bg-green-500' : 'bg-gray-400'}`}></div> |
|||
<Smartphone className="w-5 h-5 text-gray-600" /> |
|||
<div> |
|||
<div className="font-medium">Browser Geolocation</div> |
|||
<div className="text-sm text-gray-500">Primary GPS source</div> |
|||
</div> |
|||
</div> |
|||
<Switch checked={useBrowser} onCheckedChange={handleBrowserToggle} /> |
|||
</div> |
|||
|
|||
{androidConnected && ( |
|||
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"> |
|||
<div className="flex items-center gap-3"> |
|||
<div className={`w-3 h-3 rounded-full ${androidConnected && useAndroid ? 'bg-green-500' : 'bg-gray-400'}`}></div> |
|||
<Smartphone className="w-5 h-5 text-gray-600" /> |
|||
<div> |
|||
<div className="font-medium">Android Device (ADB)</div> |
|||
<div className="text-sm text-gray-500">Port 8080 - Tethered via USB</div> |
|||
</div> |
|||
</div> |
|||
<Switch checked={useAndroid} onCheckedChange={handleAndroidToggle} /> |
|||
</div> |
|||
)} |
|||
|
|||
{iosConnected && ( |
|||
<div className="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-700 rounded-lg"> |
|||
<div className="flex items-center gap-3"> |
|||
<div className={`w-3 h-3 rounded-full ${iosConnected && useIOS ? 'bg-green-500' : 'bg-gray-400'}`}></div> |
|||
<Smartphone className="w-5 h-5 text-gray-600" /> |
|||
<div> |
|||
<div className="font-medium">iOS Device</div> |
|||
<div className="text-sm text-gray-500">Port 8081 - Tethered via USB</div> |
|||
</div> |
|||
</div> |
|||
<Switch checked={useIOS} onCheckedChange={handleIOSToggle} /> |
|||
</div> |
|||
)} |
|||
</div> |
|||
</div> |
|||
|
|||
{/* Meshtastic Integration */} |
|||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6"> |
|||
<div className="flex items-center gap-3 mb-4"> |
|||
<Send className="w-6 h-6 text-purple-600" /> |
|||
<h2 className="text-lg font-semibold">Meshtastic Integration</h2> |
|||
</div> |
|||
|
|||
<div className="space-y-3"> |
|||
<div className="p-3 bg-purple-50 dark:bg-purple-900/20 rounded-lg"> |
|||
<div className="font-medium">Auto Position Broadcast</div> |
|||
<div className="text-sm text-gray-500">Sends GPS to mesh every 30 seconds</div> |
|||
</div> |
|||
|
|||
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg"> |
|||
<div className="font-medium">Manual Position Send</div> |
|||
<div className="text-sm text-gray-500">Click 'Send to Device' to broadcast now</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
|
|||
{/* Quick Actions */} |
|||
<div className="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6"> |
|||
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2> |
|||
|
|||
<div className="space-y-3"> |
|||
<button |
|||
onClick={() => { |
|||
if (position) { |
|||
navigator.clipboard.writeText(`${position.latitude}, ${position.longitude}`); |
|||
toast({ title: 'Copied!', description: 'Coordinates copied to clipboard' }); |
|||
} |
|||
}} |
|||
disabled={!position} |
|||
className="w-full p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg hover:bg-blue-100 dark:hover:bg-blue-900/30 disabled:opacity-50 text-left" |
|||
> |
|||
<div className="font-medium">Copy Coordinates</div> |
|||
<div className="text-sm text-gray-500">Copy lat/lng to clipboard</div> |
|||
</button> |
|||
|
|||
<button |
|||
onClick={() => position && window.open(`https://www.google.com/maps?q=${position.latitude},${position.longitude}`, '_blank')} |
|||
disabled={!position} |
|||
className="w-full p-3 bg-green-50 dark:bg-green-900/20 rounded-lg hover:bg-green-100 dark:hover:bg-green-900/30 disabled:opacity-50 text-left" |
|||
> |
|||
<div className="font-medium">Google Maps</div> |
|||
<div className="text-sm text-gray-500">View in Google Maps</div> |
|||
</button> |
|||
|
|||
<button |
|||
onClick={() => position && window.open(`https://www.openstreetmap.org/?mlat=${position.latitude}&mlon=${position.longitude}&zoom=15`, '_blank')} |
|||
disabled={!position} |
|||
className="w-full p-3 bg-orange-50 dark:bg-orange-900/20 rounded-lg hover:bg-orange-100 dark:hover:bg-orange-900/30 disabled:opacity-50 text-left" |
|||
> |
|||
<div className="font-medium">OpenStreetMap</div> |
|||
<div className="text-sm text-gray-500">View in OSM</div> |
|||
</button> |
|||
</div> |
|||
</div> |
|||
|
|||
</div> |
|||
</div> |
|||
</PageLayout> |
|||
); |
|||
} |
|||
Loading…
Reference in new issue