200 lines
5.9 KiB
TypeScript
Executable File
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;
|
|
}
|
|
}
|