Files
Feeld/web/src/hooks/useLocation.tsx
2026-03-20 18:49:48 -05:00

200 lines
5.9 KiB
TypeScript
Executable File

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;
}
}