geolocation direkt von fs

This commit is contained in:
2025-11-11 10:14:58 +01:00
parent bbde08f89a
commit 8e308b0d99
7 changed files with 45 additions and 219 deletions

View File

@@ -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) => {

View File

@@ -71,5 +71,6 @@ function writePreferences(profileId, patch = {}) {
module.exports = {
readPreferences,
writePreferences
writePreferences,
sanitizeLocation
};

View File

@@ -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}
/>
);
@@ -787,9 +783,7 @@ function App() {
knownStores={stores}
userLocation={userLocationWithLabel}
locationLoading={preferencesLoading}
locationSaving={locationSaving}
locationError={preferencesError}
onRequestLocation={updateLocation}
notificationPanelOpen={notificationPanelOpen}
onToggleNotificationPanel={() => setNotificationPanelOpen((prev) => !prev)}
notificationProps={sharedNotificationProps}

View File

@@ -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}
/>
)}

View File

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

View File

@@ -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>
)}

View File

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