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 { 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) => {
|
||||||
|
|||||||
@@ -71,5 +71,6 @@ function writePreferences(profileId, patch = {}) {
|
|||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
readPreferences,
|
readPreferences,
|
||||||
writePreferences
|
writePreferences,
|
||||||
|
sanitizeLocation
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>}
|
||||||
|
|||||||
@@ -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>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user