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