geolocation direkt von fs
This commit is contained in:
26
server.js
26
server.js
@@ -12,7 +12,7 @@ const adminConfig = require('./services/adminConfig');
|
||||
const { readNotificationSettings, writeNotificationSettings } = require('./services/userSettingsStore');
|
||||
const notificationService = require('./services/notificationService');
|
||||
const { readStoreWatch, writeStoreWatch, listWatcherProfiles } = require('./services/storeWatchStore');
|
||||
const { readPreferences, writePreferences } = require('./services/userPreferencesStore');
|
||||
const { readPreferences, writePreferences, sanitizeLocation } = require('./services/userPreferencesStore');
|
||||
const { readStoreStatus, writeStoreStatus } = require('./services/storeStatusStore');
|
||||
|
||||
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000;
|
||||
@@ -805,9 +805,29 @@ app.post('/api/store-watch/subscriptions', requireAuth, (req, res) => {
|
||||
res.json({ success: true, stores: persisted });
|
||||
});
|
||||
|
||||
app.get('/api/user/preferences', requireAuth, (req, res) => {
|
||||
app.get('/api/user/preferences', requireAuth, async (req, res) => {
|
||||
const preferences = readPreferences(req.session.profile.id);
|
||||
res.json(preferences);
|
||||
let location = preferences.location;
|
||||
try {
|
||||
const details = await foodsharingClient.fetchProfile(req.session.cookieHeader);
|
||||
const coords = details?.coordinates;
|
||||
const sanitized = sanitizeLocation({ lat: coords?.lat, lon: coords?.lon });
|
||||
if (sanitized) {
|
||||
const city = details?.city?.trim() || '';
|
||||
location = {
|
||||
...sanitized,
|
||||
label: city || preferences.location?.label || 'Foodsharing-Profil',
|
||||
city,
|
||||
updatedAt: sanitized.updatedAt
|
||||
};
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[PREFERENCES] Profilstandort konnte nicht geladen werden:', error.message);
|
||||
}
|
||||
res.json({
|
||||
...preferences,
|
||||
location
|
||||
});
|
||||
});
|
||||
|
||||
app.post('/api/user/preferences/location', requireAuth, (req, res) => {
|
||||
|
||||
@@ -71,5 +71,6 @@ function writePreferences(profileId, patch = {}) {
|
||||
|
||||
module.exports = {
|
||||
readPreferences,
|
||||
writePreferences
|
||||
writePreferences,
|
||||
sanitizeLocation
|
||||
};
|
||||
|
||||
20
src/App.js
20
src/App.js
@@ -120,9 +120,7 @@ function App() {
|
||||
const {
|
||||
preferences,
|
||||
loading: preferencesLoading,
|
||||
saving: locationSaving,
|
||||
error: preferencesError,
|
||||
updateLocation
|
||||
error: preferencesError
|
||||
} = useUserPreferences({
|
||||
authorizedFetch,
|
||||
sessionToken: session?.token
|
||||
@@ -747,9 +745,7 @@ function App() {
|
||||
onClearFocus={() => setFocusedStoreId(null)}
|
||||
userLocation={userLocationWithLabel}
|
||||
locationLoading={preferencesLoading}
|
||||
locationSaving={locationSaving}
|
||||
locationError={preferencesError}
|
||||
onUpdateLocation={updateLocation}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -782,14 +778,12 @@ function App() {
|
||||
<Route
|
||||
path="/store-watch"
|
||||
element={
|
||||
<StoreWatchPage
|
||||
authorizedFetch={authorizedFetch}
|
||||
knownStores={stores}
|
||||
userLocation={userLocationWithLabel}
|
||||
locationLoading={preferencesLoading}
|
||||
locationSaving={locationSaving}
|
||||
locationError={preferencesError}
|
||||
onRequestLocation={updateLocation}
|
||||
<StoreWatchPage
|
||||
authorizedFetch={authorizedFetch}
|
||||
knownStores={stores}
|
||||
userLocation={userLocationWithLabel}
|
||||
locationLoading={preferencesLoading}
|
||||
locationError={preferencesError}
|
||||
notificationPanelOpen={notificationPanelOpen}
|
||||
onToggleNotificationPanel={() => setNotificationPanelOpen((prev) => !prev)}
|
||||
notificationProps={sharedNotificationProps}
|
||||
|
||||
@@ -110,9 +110,7 @@ const DashboardView = ({
|
||||
onClearFocus,
|
||||
userLocation,
|
||||
locationLoading,
|
||||
locationSaving,
|
||||
locationError,
|
||||
onUpdateLocation
|
||||
locationError
|
||||
}) => {
|
||||
useEffect(() => {
|
||||
if (!focusedStoreId) {
|
||||
@@ -134,8 +132,6 @@ const DashboardView = ({
|
||||
row.classList.remove('dashboard-row-highlight', 'ring-4', 'ring-blue-400');
|
||||
};
|
||||
}, [focusedStoreId, onClearFocus]);
|
||||
const [geoBusy, setGeoBusy] = useState(false);
|
||||
const [geoError, setGeoError] = useState('');
|
||||
const initialTableState = useMemo(() => readConfigTableState(), []);
|
||||
const [tableSorting, setTableSorting] = useState(initialTableState.sorting);
|
||||
const [tableFilters, setTableFilters] = useState(initialTableState.columnFilters);
|
||||
@@ -396,41 +392,6 @@ const DashboardView = ({
|
||||
getFilteredRowModel: getFilteredRowModel()
|
||||
});
|
||||
|
||||
const handleDetectLocation = useCallback(() => {
|
||||
if (!navigator.geolocation) {
|
||||
setGeoError('Standortbestimmung wird von diesem Browser nicht unterstützt.');
|
||||
return;
|
||||
}
|
||||
setGeoBusy(true);
|
||||
setGeoError('');
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (position) => {
|
||||
const lat = position.coords.latitude;
|
||||
const lon = position.coords.longitude;
|
||||
await onUpdateLocation?.({ lat, lon });
|
||||
setGeoBusy(false);
|
||||
},
|
||||
(error) => {
|
||||
setGeoBusy(false);
|
||||
setGeoError(
|
||||
error.code === error.PERMISSION_DENIED
|
||||
? 'Zugriff auf den Standort wurde verweigert.'
|
||||
: 'Standort konnte nicht ermittelt werden.'
|
||||
);
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 0
|
||||
}
|
||||
);
|
||||
}, [onUpdateLocation]);
|
||||
|
||||
const handleClearLocation = useCallback(async () => {
|
||||
setGeoError('');
|
||||
await onUpdateLocation?.(null);
|
||||
}, [onUpdateLocation]);
|
||||
|
||||
const {
|
||||
error: notificationError,
|
||||
message: notificationMessage,
|
||||
@@ -533,13 +494,7 @@ const DashboardView = ({
|
||||
ntfyPreviewUrl={ntfyPreviewUrl}
|
||||
location={userLocation}
|
||||
locationLoading={locationLoading}
|
||||
locationSaving={locationSaving || geoBusy}
|
||||
locationError={locationError || geoError}
|
||||
onDetectLocation={() => {
|
||||
setGeoError('');
|
||||
handleDetectLocation();
|
||||
}}
|
||||
onClearLocation={handleClearLocation}
|
||||
locationError={locationError}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -15,10 +15,7 @@ const NotificationPanel = ({
|
||||
ntfyPreviewUrl,
|
||||
location,
|
||||
locationLoading,
|
||||
locationSaving,
|
||||
locationError,
|
||||
onDetectLocation,
|
||||
onClearLocation
|
||||
locationError
|
||||
}) => {
|
||||
return (
|
||||
<div className="mb-6 border border-gray-200 rounded-lg p-4 bg-gray-50">
|
||||
@@ -172,29 +169,10 @@ const NotificationPanel = ({
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-blue-900">Standort für Entfernungssortierung</h3>
|
||||
<p className="text-sm text-blue-800">
|
||||
Der Standort wird für die Entfernungskalkulation im Monitoring genutzt. Nur für dich sichtbar.
|
||||
Der Standort wird für die Entfernungskalkulation im Monitoring genutzt. Nur für dich sichtbar. Die Daten
|
||||
stammen direkt aus deinem Foodsharing-Profil.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onDetectLocation?.()}
|
||||
className="px-4 py-2 rounded-md bg-blue-600 text-white text-sm disabled:opacity-60"
|
||||
disabled={!onDetectLocation || locationLoading || locationSaving}
|
||||
>
|
||||
{locationSaving ? 'Speichere...' : 'Standort ermitteln'}
|
||||
</button>
|
||||
{location && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClearLocation?.()}
|
||||
className="px-4 py-2 rounded-md border border-blue-300 text-blue-700 text-sm bg-white disabled:opacity-60"
|
||||
disabled={!onClearLocation || locationSaving}
|
||||
>
|
||||
Entfernen
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-3 text-sm text-blue-900">
|
||||
{locationLoading ? (
|
||||
@@ -216,7 +194,7 @@ const NotificationPanel = ({
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<p>Kein Standort hinterlegt.</p>
|
||||
<p>Standortdaten werden direkt aus deinem Foodsharing-Profil übernommen.</p>
|
||||
)}
|
||||
</div>
|
||||
{locationError && <p className="mt-2 text-sm text-red-600">{locationError}</p>}
|
||||
|
||||
@@ -93,9 +93,7 @@ const StoreWatchPage = ({
|
||||
authorizedFetch,
|
||||
knownStores = [],
|
||||
userLocation,
|
||||
onRequestLocation,
|
||||
locationLoading = false,
|
||||
locationSaving = false,
|
||||
locationError = '',
|
||||
notificationPanelOpen = false,
|
||||
onToggleNotificationPanel = () => {},
|
||||
@@ -125,12 +123,6 @@ const StoreWatchPage = ({
|
||||
const initialTableState = useMemo(() => readWatchTableState(), []);
|
||||
const [sorting, setSorting] = useState(initialTableState.sorting);
|
||||
const [columnFilters, setColumnFilters] = useState(initialTableState.columnFilters);
|
||||
const [locationPromptTriggered, setLocationPromptTriggered] = useState(false);
|
||||
const [locationPromptPending, setLocationPromptPending] = useState(false);
|
||||
const [locationPromptError, setLocationPromptError] = useState('');
|
||||
const combinedLocationError = locationError || locationPromptError;
|
||||
const locationActionBusy = locationSaving || locationPromptPending;
|
||||
const formatCoordinate = (value) => (Number.isFinite(value) ? Number(value).toFixed(4) : '–');
|
||||
const aggregatedRegionStores = useMemo(() => {
|
||||
const list = [];
|
||||
Object.values(storesByRegion).forEach((entry) => {
|
||||
@@ -182,63 +174,6 @@ const StoreWatchPage = ({
|
||||
persistWatchTableState({ sorting, columnFilters });
|
||||
}, [sorting, columnFilters]);
|
||||
|
||||
const requestLocation = useCallback(() => {
|
||||
if (!onRequestLocation) {
|
||||
return;
|
||||
}
|
||||
setLocationPromptTriggered(true);
|
||||
if (typeof window === 'undefined' || typeof navigator === 'undefined' || !navigator.geolocation) {
|
||||
setLocationPromptPending(false);
|
||||
setLocationPromptError('Standortbestimmung wird von diesem Browser nicht unterstützt.');
|
||||
return;
|
||||
}
|
||||
setLocationPromptPending(true);
|
||||
setLocationPromptError('');
|
||||
navigator.geolocation.getCurrentPosition(
|
||||
async (position) => {
|
||||
setLocationPromptPending(false);
|
||||
try {
|
||||
await onRequestLocation({
|
||||
lat: position.coords.latitude,
|
||||
lon: position.coords.longitude
|
||||
});
|
||||
} catch {
|
||||
setLocationPromptError('Standort konnte nicht gespeichert werden.');
|
||||
}
|
||||
},
|
||||
(geoError) => {
|
||||
setLocationPromptPending(false);
|
||||
setLocationPromptError(
|
||||
geoError.code === geoError.PERMISSION_DENIED
|
||||
? 'Zugriff auf den Standort wurde verweigert. Bitte erlaube den Zugriff im Browser.'
|
||||
: 'Standort konnte nicht automatisch ermittelt werden.'
|
||||
);
|
||||
},
|
||||
{
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 0
|
||||
}
|
||||
);
|
||||
}, [onRequestLocation]);
|
||||
|
||||
useEffect(() => {
|
||||
if (userLocation || locationLoading || !onRequestLocation || locationPromptTriggered) {
|
||||
return;
|
||||
}
|
||||
requestLocation();
|
||||
}, [userLocation, locationLoading, onRequestLocation, locationPromptTriggered, requestLocation]);
|
||||
|
||||
const handleManualDetectLocation = useCallback(() => {
|
||||
setLocationPromptError('');
|
||||
requestLocation();
|
||||
}, [requestLocation]);
|
||||
|
||||
const handleClearStoredLocation = useCallback(async () => {
|
||||
setLocationPromptError('');
|
||||
await onRequestLocation?.(null);
|
||||
}, [onRequestLocation]);
|
||||
|
||||
const watchedIds = useMemo(
|
||||
() => new Set(watchList.map((entry) => String(entry.storeId))),
|
||||
[watchList]
|
||||
@@ -964,10 +899,7 @@ const StoreWatchPage = ({
|
||||
{...notificationProps}
|
||||
location={displayLocation}
|
||||
locationLoading={locationLoading}
|
||||
locationSaving={locationActionBusy}
|
||||
locationError={combinedLocationError}
|
||||
onDetectLocation={handleManualDetectLocation}
|
||||
onClearLocation={handleClearStoredLocation}
|
||||
locationError={locationError}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -984,25 +916,12 @@ const StoreWatchPage = ({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!userLocation && !locationLoading && onRequestLocation && (
|
||||
{!userLocation && !locationLoading && (
|
||||
<div className="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-900">
|
||||
{locationActionBusy ? (
|
||||
<p>Standort wird automatisch angefragt, um Entfernungen berechnen zu können...</p>
|
||||
) : combinedLocationError ? (
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<p className="flex-1">{combinedLocationError}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleManualDetectLocation}
|
||||
className="inline-flex items-center justify-center rounded-md bg-blue-600 px-4 py-2 text-sm font-semibold text-white hover:bg-blue-700 disabled:opacity-60"
|
||||
disabled={locationActionBusy}
|
||||
>
|
||||
Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<p>Bitte bestätige die Standortabfrage deines Browsers für Entfernungssortierung.</p>
|
||||
)}
|
||||
<p>
|
||||
Standortdaten für die Entfernungssortierung stammen automatisch aus deinem Foodsharing-Profil. Ergänze
|
||||
dort deine Koordinaten, falls sie fehlen.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,12 +1,9 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
|
||||
const PREFERENCES_ENDPOINT = '/api/user/preferences';
|
||||
const LOCATION_ENDPOINT = '/api/user/preferences/location';
|
||||
|
||||
const useUserPreferences = ({ authorizedFetch, sessionToken }) => {
|
||||
const [preferences, setPreferences] = useState(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
|
||||
const loadPreferences = useCallback(async () => {
|
||||
@@ -30,42 +27,6 @@ const useUserPreferences = ({ authorizedFetch, sessionToken }) => {
|
||||
}
|
||||
}, [authorizedFetch, sessionToken]);
|
||||
|
||||
const updateLocation = useCallback(
|
||||
async (coords) => {
|
||||
if (!sessionToken || !authorizedFetch) {
|
||||
return false;
|
||||
}
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
const payload =
|
||||
coords && typeof coords.lat === 'number' && typeof coords.lon === 'number'
|
||||
? { lat: coords.lat, lon: coords.lon }
|
||||
: { lat: null, lon: null };
|
||||
const response = await authorizedFetch(LOCATION_ENDPOINT, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}`);
|
||||
}
|
||||
const data = await response.json();
|
||||
setPreferences((prev) => ({
|
||||
...(prev || {}),
|
||||
location: data.location || null
|
||||
}));
|
||||
return true;
|
||||
} catch (err) {
|
||||
setError(`Standort konnte nicht aktualisiert werden: ${err.message}`);
|
||||
return false;
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
},
|
||||
[authorizedFetch, sessionToken]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (sessionToken) {
|
||||
loadPreferences();
|
||||
@@ -77,10 +38,8 @@ const useUserPreferences = ({ authorizedFetch, sessionToken }) => {
|
||||
return {
|
||||
preferences,
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
loadPreferences,
|
||||
updateLocation
|
||||
loadPreferences
|
||||
};
|
||||
};
|
||||
|
||||
|
||||
Reference in New Issue
Block a user