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 { readNotificationSettings, writeNotificationSettings } = require('./services/userSettingsStore');
const notificationService = require('./services/notificationService'); const notificationService = require('./services/notificationService');
const { readStoreWatch, writeStoreWatch, listWatcherProfiles } = require('./services/storeWatchStore'); 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 { readStoreStatus, writeStoreStatus } = require('./services/storeStatusStore');
const ONE_YEAR_MS = 365 * 24 * 60 * 60 * 1000; 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 }); 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); 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) => { app.post('/api/user/preferences/location', requireAuth, (req, res) => {

View File

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

View File

@@ -120,9 +120,7 @@ function App() {
const { const {
preferences, preferences,
loading: preferencesLoading, loading: preferencesLoading,
saving: locationSaving, error: preferencesError
error: preferencesError,
updateLocation
} = useUserPreferences({ } = useUserPreferences({
authorizedFetch, authorizedFetch,
sessionToken: session?.token sessionToken: session?.token
@@ -747,9 +745,7 @@ function App() {
onClearFocus={() => setFocusedStoreId(null)} onClearFocus={() => setFocusedStoreId(null)}
userLocation={userLocationWithLabel} userLocation={userLocationWithLabel}
locationLoading={preferencesLoading} locationLoading={preferencesLoading}
locationSaving={locationSaving}
locationError={preferencesError} locationError={preferencesError}
onUpdateLocation={updateLocation}
/> />
); );
@@ -787,9 +783,7 @@ function App() {
knownStores={stores} knownStores={stores}
userLocation={userLocationWithLabel} userLocation={userLocationWithLabel}
locationLoading={preferencesLoading} locationLoading={preferencesLoading}
locationSaving={locationSaving}
locationError={preferencesError} locationError={preferencesError}
onRequestLocation={updateLocation}
notificationPanelOpen={notificationPanelOpen} notificationPanelOpen={notificationPanelOpen}
onToggleNotificationPanel={() => setNotificationPanelOpen((prev) => !prev)} onToggleNotificationPanel={() => setNotificationPanelOpen((prev) => !prev)}
notificationProps={sharedNotificationProps} notificationProps={sharedNotificationProps}

View File

@@ -110,9 +110,7 @@ const DashboardView = ({
onClearFocus, onClearFocus,
userLocation, userLocation,
locationLoading, locationLoading,
locationSaving, locationError
locationError,
onUpdateLocation
}) => { }) => {
useEffect(() => { useEffect(() => {
if (!focusedStoreId) { if (!focusedStoreId) {
@@ -134,8 +132,6 @@ const DashboardView = ({
row.classList.remove('dashboard-row-highlight', 'ring-4', 'ring-blue-400'); row.classList.remove('dashboard-row-highlight', 'ring-4', 'ring-blue-400');
}; };
}, [focusedStoreId, onClearFocus]); }, [focusedStoreId, onClearFocus]);
const [geoBusy, setGeoBusy] = useState(false);
const [geoError, setGeoError] = useState('');
const initialTableState = useMemo(() => readConfigTableState(), []); const initialTableState = useMemo(() => readConfigTableState(), []);
const [tableSorting, setTableSorting] = useState(initialTableState.sorting); const [tableSorting, setTableSorting] = useState(initialTableState.sorting);
const [tableFilters, setTableFilters] = useState(initialTableState.columnFilters); const [tableFilters, setTableFilters] = useState(initialTableState.columnFilters);
@@ -396,41 +392,6 @@ const DashboardView = ({
getFilteredRowModel: getFilteredRowModel() 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 { const {
error: notificationError, error: notificationError,
message: notificationMessage, message: notificationMessage,
@@ -533,13 +494,7 @@ const DashboardView = ({
ntfyPreviewUrl={ntfyPreviewUrl} ntfyPreviewUrl={ntfyPreviewUrl}
location={userLocation} location={userLocation}
locationLoading={locationLoading} locationLoading={locationLoading}
locationSaving={locationSaving || geoBusy} locationError={locationError}
locationError={locationError || geoError}
onDetectLocation={() => {
setGeoError('');
handleDetectLocation();
}}
onClearLocation={handleClearLocation}
/> />
)} )}

View File

@@ -15,10 +15,7 @@ const NotificationPanel = ({
ntfyPreviewUrl, ntfyPreviewUrl,
location, location,
locationLoading, locationLoading,
locationSaving, locationError
locationError,
onDetectLocation,
onClearLocation
}) => { }) => {
return ( return (
<div className="mb-6 border border-gray-200 rounded-lg p-4 bg-gray-50"> <div className="mb-6 border border-gray-200 rounded-lg p-4 bg-gray-50">
@@ -172,29 +169,10 @@ const NotificationPanel = ({
<div> <div>
<h3 className="text-lg font-semibold text-blue-900">Standort für Entfernungssortierung</h3> <h3 className="text-lg font-semibold text-blue-900">Standort für Entfernungssortierung</h3>
<p className="text-sm text-blue-800"> <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> </p>
</div> </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>
<div className="mt-3 text-sm text-blue-900"> <div className="mt-3 text-sm text-blue-900">
{locationLoading ? ( {locationLoading ? (
@@ -216,7 +194,7 @@ const NotificationPanel = ({
)} )}
</div> </div>
) : ( ) : (
<p>Kein Standort hinterlegt.</p> <p>Standortdaten werden direkt aus deinem Foodsharing-Profil übernommen.</p>
)} )}
</div> </div>
{locationError && <p className="mt-2 text-sm text-red-600">{locationError}</p>} {locationError && <p className="mt-2 text-sm text-red-600">{locationError}</p>}

View File

@@ -93,9 +93,7 @@ const StoreWatchPage = ({
authorizedFetch, authorizedFetch,
knownStores = [], knownStores = [],
userLocation, userLocation,
onRequestLocation,
locationLoading = false, locationLoading = false,
locationSaving = false,
locationError = '', locationError = '',
notificationPanelOpen = false, notificationPanelOpen = false,
onToggleNotificationPanel = () => {}, onToggleNotificationPanel = () => {},
@@ -125,12 +123,6 @@ const StoreWatchPage = ({
const initialTableState = useMemo(() => readWatchTableState(), []); const initialTableState = useMemo(() => readWatchTableState(), []);
const [sorting, setSorting] = useState(initialTableState.sorting); const [sorting, setSorting] = useState(initialTableState.sorting);
const [columnFilters, setColumnFilters] = useState(initialTableState.columnFilters); 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 aggregatedRegionStores = useMemo(() => {
const list = []; const list = [];
Object.values(storesByRegion).forEach((entry) => { Object.values(storesByRegion).forEach((entry) => {
@@ -182,63 +174,6 @@ const StoreWatchPage = ({
persistWatchTableState({ sorting, columnFilters }); persistWatchTableState({ sorting, columnFilters });
}, [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( const watchedIds = useMemo(
() => new Set(watchList.map((entry) => String(entry.storeId))), () => new Set(watchList.map((entry) => String(entry.storeId))),
[watchList] [watchList]
@@ -964,10 +899,7 @@ const StoreWatchPage = ({
{...notificationProps} {...notificationProps}
location={displayLocation} location={displayLocation}
locationLoading={locationLoading} locationLoading={locationLoading}
locationSaving={locationActionBusy} locationError={locationError}
locationError={combinedLocationError}
onDetectLocation={handleManualDetectLocation}
onClearLocation={handleClearStoredLocation}
/> />
)} )}
@@ -984,25 +916,12 @@ const StoreWatchPage = ({
</div> </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"> <div className="mb-6 rounded-lg border border-blue-200 bg-blue-50 p-4 text-sm text-blue-900">
{locationActionBusy ? ( <p>
<p>Standort wird automatisch angefragt, um Entfernungen berechnen zu können...</p> Standortdaten für die Entfernungssortierung stammen automatisch aus deinem Foodsharing-Profil. Ergänze
) : combinedLocationError ? ( dort deine Koordinaten, falls sie fehlen.
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between"> </p>
<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>
)}
</div> </div>
)} )}

View File

@@ -1,12 +1,9 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
const PREFERENCES_ENDPOINT = '/api/user/preferences'; const PREFERENCES_ENDPOINT = '/api/user/preferences';
const LOCATION_ENDPOINT = '/api/user/preferences/location';
const useUserPreferences = ({ authorizedFetch, sessionToken }) => { const useUserPreferences = ({ authorizedFetch, sessionToken }) => {
const [preferences, setPreferences] = useState(null); const [preferences, setPreferences] = useState(null);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const loadPreferences = useCallback(async () => { const loadPreferences = useCallback(async () => {
@@ -30,42 +27,6 @@ const useUserPreferences = ({ authorizedFetch, sessionToken }) => {
} }
}, [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(() => { useEffect(() => {
if (sessionToken) { if (sessionToken) {
loadPreferences(); loadPreferences();
@@ -77,10 +38,8 @@ const useUserPreferences = ({ authorizedFetch, sessionToken }) => {
return { return {
preferences, preferences,
loading, loading,
saving,
error, error,
loadPreferences, loadPreferences
updateLocation
}; };
}; };