Initial commit
This commit is contained in:
199
web/src/hooks/useLocation.tsx
Executable file
199
web/src/hooks/useLocation.tsx
Executable file
@@ -0,0 +1,199 @@
|
||||
import { createContext, useContext, useState, useEffect, ReactNode } from 'react';
|
||||
import * as dataSync from '../api/dataSync';
|
||||
|
||||
// UUID generator that works in non-secure contexts (HTTP)
|
||||
function generateUUID(): string {
|
||||
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = c === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
export interface SavedLocation {
|
||||
id: string;
|
||||
name: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
}
|
||||
|
||||
export interface LocationState {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
name?: string;
|
||||
}
|
||||
|
||||
interface LocationContextType {
|
||||
location: LocationState | null;
|
||||
savedLocations: SavedLocation[];
|
||||
setLocation: (location: LocationState | null) => void;
|
||||
saveLocation: (name: string, lat: number, lng: number) => void;
|
||||
deleteLocation: (id: string) => void;
|
||||
clearLocation: () => void;
|
||||
}
|
||||
|
||||
const LocationContext = createContext<LocationContextType | null>(null);
|
||||
|
||||
const STORAGE_KEY = 'feeld_locations';
|
||||
const CURRENT_LOCATION_KEY = 'feeld_current_location';
|
||||
|
||||
export function LocationProvider({ children }: { children: ReactNode }) {
|
||||
const [location, setLocationState] = useState<LocationState | null>(() => {
|
||||
const saved = localStorage.getItem(CURRENT_LOCATION_KEY);
|
||||
return saved ? JSON.parse(saved) : null;
|
||||
});
|
||||
|
||||
const [savedLocations, setSavedLocations] = useState<SavedLocation[]>(() => {
|
||||
const saved = localStorage.getItem(STORAGE_KEY);
|
||||
return saved ? JSON.parse(saved) : [];
|
||||
});
|
||||
|
||||
const [hasSyncedFromServer, setHasSyncedFromServer] = useState(false);
|
||||
|
||||
// Fetch from server on mount and merge with localStorage
|
||||
useEffect(() => {
|
||||
const syncFromServer = async () => {
|
||||
try {
|
||||
const serverData = await dataSync.getAllFromServer();
|
||||
if (serverData) {
|
||||
// Merge saved locations from server
|
||||
if (serverData.savedLocations && serverData.savedLocations.length > 0) {
|
||||
setSavedLocations(prev => {
|
||||
const merged = [...serverData.savedLocations];
|
||||
for (const local of prev) {
|
||||
if (!merged.some((s: SavedLocation) => s.id === local.id)) {
|
||||
merged.push(local);
|
||||
}
|
||||
}
|
||||
// Update localStorage with merged data
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(merged));
|
||||
return merged;
|
||||
});
|
||||
}
|
||||
|
||||
// Restore current location if we don't have one
|
||||
if (serverData.currentLocation && !location) {
|
||||
setLocationState(serverData.currentLocation);
|
||||
localStorage.setItem(CURRENT_LOCATION_KEY, JSON.stringify(serverData.currentLocation));
|
||||
}
|
||||
|
||||
// Restore custom location if we don't have one
|
||||
if (serverData.customLocation && !location) {
|
||||
setLocationState(serverData.customLocation);
|
||||
localStorage.setItem(CURRENT_LOCATION_KEY, JSON.stringify(serverData.customLocation));
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to sync locations from server:', e);
|
||||
} finally {
|
||||
setHasSyncedFromServer(true);
|
||||
}
|
||||
};
|
||||
|
||||
syncFromServer();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (location) {
|
||||
localStorage.setItem(CURRENT_LOCATION_KEY, JSON.stringify(location));
|
||||
dataSync.setData('currentLocation', location);
|
||||
} else {
|
||||
localStorage.removeItem(CURRENT_LOCATION_KEY);
|
||||
}
|
||||
}, [location]);
|
||||
|
||||
// Only sync to server after initial load from server is complete
|
||||
useEffect(() => {
|
||||
if (!hasSyncedFromServer) return;
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(savedLocations));
|
||||
// Sync to server
|
||||
dataSync.setData('savedLocations', savedLocations);
|
||||
// Also sync to dedicated saved-locations endpoint for rotation cron
|
||||
fetch('/api/saved-locations', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ locations: savedLocations }),
|
||||
}).catch(() => {}); // Best-effort
|
||||
}, [savedLocations, hasSyncedFromServer]);
|
||||
|
||||
const setLocation = (loc: LocationState | null) => {
|
||||
setLocationState(loc);
|
||||
// Sync to server
|
||||
if (loc) {
|
||||
dataSync.setData('customLocation', loc);
|
||||
}
|
||||
};
|
||||
|
||||
const saveLocation = (name: string, latitude: number, longitude: number) => {
|
||||
const newLocation: SavedLocation = {
|
||||
id: generateUUID(),
|
||||
name,
|
||||
latitude,
|
||||
longitude,
|
||||
};
|
||||
setSavedLocations((prev) => [...prev, newLocation]);
|
||||
};
|
||||
|
||||
const deleteLocation = (id: string) => {
|
||||
setSavedLocations((prev) => prev.filter((loc) => loc.id !== id));
|
||||
};
|
||||
|
||||
const clearLocation = () => {
|
||||
setLocationState(null);
|
||||
};
|
||||
|
||||
return (
|
||||
<LocationContext.Provider
|
||||
value={{
|
||||
location,
|
||||
savedLocations,
|
||||
setLocation,
|
||||
saveLocation,
|
||||
deleteLocation,
|
||||
clearLocation,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</LocationContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useLocation() {
|
||||
const context = useContext(LocationContext);
|
||||
if (!context) {
|
||||
throw new Error('useLocation must be used within a LocationProvider');
|
||||
}
|
||||
return context;
|
||||
}
|
||||
|
||||
// Geocoding helper using OpenStreetMap Nominatim (free, no API key needed)
|
||||
export async function geocodeAddress(address: string): Promise<{ lat: number; lng: number; displayName: string } | null> {
|
||||
try {
|
||||
const response = await fetch(
|
||||
`https://nominatim.openstreetmap.org/search?format=json&q=${encodeURIComponent(address)}&limit=1`,
|
||||
{
|
||||
headers: {
|
||||
'User-Agent': 'FeeldWebApp/1.0',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (data.length > 0) {
|
||||
return {
|
||||
lat: parseFloat(data[0].lat),
|
||||
lng: parseFloat(data[0].lon),
|
||||
displayName: data[0].display_name,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('Geocoding error:', error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user