Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
f260928
fix: photos are optional
raven-wing Jul 29, 2026
f72d4e8
added missing file
raven-wing Jul 29, 2026
eb31ece
fix: error is actually visible now
raven-wing Aug 2, 2026
2b0b6f9
docs added
raven-wing Aug 2, 2026
7d12b3a
fix lint
raven-wing Aug 2, 2026
6cd52a6
added missing files
raven-wing Aug 2, 2026
c538556
fix docs
raven-wing Aug 2, 2026
4553d7e
fixes
raven-wing Aug 2, 2026
042458a
fix scroll
raven-wing Aug 2, 2026
bae24fa
load spinner
raven-wing Aug 2, 2026
9a37fcc
fix docs
raven-wing Aug 2, 2026
a275e24
fix docs
raven-wing Aug 2, 2026
02ddc3e
fix
raven-wing Aug 3, 2026
f9af39a
little cleanup
raven-wing Aug 3, 2026
c8fc966
fixes after update
raven-wing Aug 4, 2026
7a0a319
fixed comments
raven-wing Aug 4, 2026
8a880e4
little refactor
raven-wing Aug 4, 2026
8d785aa
removed compressing from our site
raven-wing Aug 4, 2026
f39c8da
fixes
raven-wing Aug 4, 2026
3df1861
added dependency
raven-wing Aug 4, 2026
5e9009c
splitted suggest new point behaviour
raven-wing Aug 4, 2026
4385180
little refactor
raven-wing Aug 4, 2026
4c6d28b
dead code
raven-wing Aug 4, 2026
afa8f07
code cleanup
raven-wing Aug 4, 2026
434fb69
some cleanup
raven-wing Aug 4, 2026
5fc4ecd
lint fixes
raven-wing Aug 4, 2026
630b97d
fix after review
raven-wing Aug 4, 2026
c9347de
removed dead code
raven-wing Aug 4, 2026
ba408bb
refactor
raven-wing Aug 8, 2026
ec63578
fix licenses
raven-wing Aug 8, 2026
f3ff1cc
refactor
raven-wing Aug 8, 2026
2f0c4bc
remove some extra check
raven-wing Aug 8, 2026
bc99d6c
simplify
raven-wing Aug 10, 2026
9993bd2
fix after review
raven-wing Aug 13, 2026
b288dc9
fixes after review
raven-wing Aug 14, 2026
e94bb19
fix after review
raven-wing Aug 14, 2026
31c7915
little refactor
raven-wing Aug 14, 2026
fb361bc
lint fix
raven-wing Aug 15, 2026
f58f4c4
refactor test
raven-wing Aug 16, 2026
2cf6fa0
some renames
raven-wing Aug 16, 2026
22b9dad
added docs with feature flags
raven-wing Aug 16, 2026
32d1b49
docs fix
raven-wing Aug 16, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 66 additions & 44 deletions frontend/src/components/Map/components/SuggestNewPointButton.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,12 +11,12 @@ import {
MenuItem,
InputLabel,
FormControl,
Snackbar,
IconButton,
Checkbox,
ListItemText,
OutlinedInput,
Tooltip,
Alert,
} from '@mui/material';

import AddAPhotoIcon from '@mui/icons-material/AddAPhoto';
Expand All @@ -29,6 +29,8 @@ import { buttonStyle, getLocationAwareStyles } from '../../../styles/buttonStyle
import { getCsrfToken } from '../../../utils/csrf';
import { useLocation } from '../context/LocationContext';
import { httpService } from '../../../services/http/httpService';
import { toast } from '../../../utils/toast';
import { compressImageToJpeg } from '../../../utils/imageCompression';

// Map a category's options to a { key: translation } object.
// Options come as [[key, translation], ...] or [key, ...].
Expand All @@ -44,20 +46,21 @@ const mapCategoryOptions = categoryOptions => {
return optionMap;
};

// Build { fieldNames, options } translation maps from the categories API shape.
// Build { fieldNames, options } translation maps from httpService.getCategoriesData()'s
// { categories: [{ categoryKey, categoryName, options }, ...] } shape.
const buildCategoryTranslations = categoriesData => {
const fieldNames = {};
const options = {};

categoriesData.forEach(categoryData => {
const [categoryKey, categoryName] = categoryData[0];
fieldNames[categoryKey] = categoryName;
(categoriesData.categories || []).forEach(
({ categoryKey, categoryName, options: categoryOptions }) => {
fieldNames[categoryKey] = categoryName;

const categoryOptions = categoryData[1];
if (categoryOptions && categoryOptions.length > 0) {
options[categoryKey] = mapCategoryOptions(categoryOptions);
}
});
if (categoryOptions && categoryOptions.length > 0) {
options[categoryKey] = mapCategoryOptions(categoryOptions);
}
},
);

return { fieldNames, options };
};
Expand All @@ -75,10 +78,12 @@ export const SuggestNewPointButton = () => {
const { t } = useTranslation();
const { locationGranted, userPosition, requestLocationWithFeedback } = useLocation();
const [showNewPointBox, setShowNewPointSuggestionBox] = useState(false);
const [snackbarOpen, setSnackbarOpen] = useState(false);
const [snackbarMessage, setSnackbarMessage] = useState('');
const [photo, setPhoto] = useState(null);
const [photoURL, setPhotoURL] = useState(null);
// Rendered inline in the dialog rather than as a toast: this dialog sits inside the
// map's component tree, where an ancestor (e.g. a Leaflet pane) can trap a toast's
// z-index in its own stacking context, leaving it hidden behind the dialog itself.
const [formError, setFormError] = useState(null);
const [categoryTranslations, setCategoryTranslations] = useState({
fieldNames: {},
options: {},
Expand All @@ -98,8 +103,15 @@ export const SuggestNewPointButton = () => {
fetchCategories();
}, []);

// Read location schema from global object
const locationSchema = globalThis.LOCATION_SCHEMA || { obligatory_fields: [], categories: {} };
// Read location schema from global object. `photo` mirrors the backend's AttachmentConfig
// (see goodmap.py) so the frontend never has to guess/duplicate the limits it enforces.
const locationSchema = globalThis.LOCATION_SCHEMA || {
obligatory_fields: [],
categories: {},
photo: { allowed_mime_types: [], max_size_bytes: 0 },
};
const { allowed_mime_types: allowedPhotoMimeTypes, max_size_bytes: maxPhotoSizeBytes } =
locationSchema.photo;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// Initialize dynamic form fields based on schema
const initializeFormFields = () => {
Expand All @@ -121,7 +133,10 @@ export const SuggestNewPointButton = () => {
const [formFields, setFormFields] = useState(initializeFormFields);

const handleNewPointButton = () => {
requestLocationWithFeedback(() => setShowNewPointSuggestionBox(true));
requestLocationWithFeedback(() => {
setFormError(null);
setShowNewPointSuggestionBox(true);
});
};

const handleLocateMe = () => {
Expand All @@ -130,29 +145,38 @@ export const SuggestNewPointButton = () => {

const handleCloseNewPointBox = () => {
setShowNewPointSuggestionBox(false);
setFormError(null);
};

const handleSnackbarClose = (event, reason) => {
if (reason === 'clickaway') {
return;
}

setSnackbarOpen(false);
};

const handlePhotoUpload = event => {
const handlePhotoUpload = async event => {
const file = event.target.files[0];
if (!file) {
return;
}
const fileSizeMB = file.size / 1024 / 1024;
if (fileSizeMB > 5) {
setSnackbarMessage(t('fileTooLarge'));
setSnackbarOpen(true);

if (allowedPhotoMimeTypes.includes(file.type) && file.size <= maxPhotoSizeBytes) {
setFormError(null);
setPhoto(file);
setPhotoURL(URL.createObjectURL(file));
return;
}
setPhoto(file);
setPhotoURL(URL.createObjectURL(file));

// Anything else (wrong format, oversized, or both) gets normalized to what the
// backend accepts: oversized photos are usually just high-resolution camera shots
// rather than genuinely undersizable, and re-encoding also fixes the format.
try {
const compressed = await compressImageToJpeg(file, { maxSizeBytes: maxPhotoSizeBytes });
if (compressed.size > maxPhotoSizeBytes) {
setFormError(t('fileTooLarge'));
return;
}
setFormError(null);
setPhoto(compressed);
setPhotoURL(URL.createObjectURL(compressed));
} catch (error) {
console.error('Photo processing failed:', error);
setFormError(t('photoProcessingFailed'));
}
};

const handleFieldChange = fieldName => event => {
Expand All @@ -161,11 +185,11 @@ export const SuggestNewPointButton = () => {

const handleConfirmNewPoint = async event => {
event.preventDefault();
setFormError(null);

// Validate user position is available
if (!userPosition || userPosition.lat === null || userPosition.lng === null) {
setSnackbarMessage(t('locationNotAvailable'));
setSnackbarOpen(true);
setFormError(t('locationNotAvailable'));
return;
}

Expand All @@ -184,8 +208,7 @@ export const SuggestNewPointButton = () => {
});

if (emptyFields.length > 0) {
setSnackbarMessage(t('fillRequiredFields', { fields: emptyFields.join(', ') }));
setSnackbarOpen(true);
setFormError(t('fillRequiredFields', { fields: emptyFields.join(', ') }));
return;
}

Expand Down Expand Up @@ -213,8 +236,7 @@ export const SuggestNewPointButton = () => {
'X-CSRFToken': csrfToken,
},
});
setSnackbarMessage(t('locationSuggestedSuccess'));
setSnackbarOpen(true);
toast.success(t('locationSuggestedSuccess'));

// Reset form after successful submission
setFormFields(initializeFormFields());
Expand All @@ -225,8 +247,9 @@ export const SuggestNewPointButton = () => {
setShowNewPointSuggestionBox(false);
} catch (error) {
console.error('Error suggesting new point:', error);
setSnackbarMessage(t('locationSuggestedError'));
setSnackbarOpen(true);
// Surface the backend's specific reason (e.g. photo format/size) when available,
// instead of a generic message that hides why the submission was rejected.
setFormError(error.response?.data?.message || t('locationSuggestedError'));
// Dialog stays open on error so user can retry
}
};
Expand Down Expand Up @@ -346,6 +369,11 @@ export const SuggestNewPointButton = () => {
<DialogTitle>{t('suggestNewPointDialogTitle')}</DialogTitle>
<form onSubmit={handleConfirmNewPoint}>
<DialogContent>
{formError && (
<Alert severity="error" sx={{ mb: 2 }}>
{formError}
</Alert>
)}
<Box display="flex" alignItems="center" gap={2}>
<TextField
label={t('yourPosition')}
Expand Down Expand Up @@ -396,12 +424,6 @@ export const SuggestNewPointButton = () => {
</DialogActions>
</form>
</Dialog>
<Snackbar
open={snackbarOpen}
autoHideDuration={6000}
onClose={handleSnackbarClose}
message={snackbarMessage}
/>
</>
);
};
17 changes: 14 additions & 3 deletions frontend/src/components/common/AppToaster.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import React from 'react';
import { createPortal } from 'react-dom';
import toast, { ToastBar, Toaster } from 'react-hot-toast';
import { IconButton } from '@mui/material';
import Close from '@mui/icons-material/Close';
Expand All @@ -14,12 +15,21 @@ import { useMaxToasts } from '../../utils/hooks/useMaxToasts';
export const AppToaster = () => {
useMaxToasts();

return (
// Portalled to document.body (like MUI's Dialog) rather than rendered in place:
// this component lives deep inside the map's component tree, where an ancestor
// (e.g. a Leaflet pane) can establish its own stacking context and trap the
// toast's z-index there, so it loses to the Dialog's portal-level stacking no
// matter how high the z-index is set.
return createPortal(
<Toaster
position="top-center"
reverseOrder={false}
gutter={8}
containerStyle={{ zIndex: 99999999, top: 120 }}
// Centered on the viewport rather than anchored to a fixed pixel offset from
// the top: goodmap is embedded in third-party sites with varying header
// heights, so an assumed offset (e.g. "top: 120") can land the toast behind
// the host page's own header instead of over the dialog it relates to.
containerStyle={{ zIndex: 99999999, top: '50%', transform: 'translateY(-50%)' }}
toastOptions={{
duration: 8000,
style: {
Expand All @@ -43,6 +53,7 @@ export const AppToaster = () => {
)}
</ToastBar>
)}
</Toaster>
</Toaster>,
document.body,
);
};
1 change: 1 addition & 0 deletions frontend/src/locales/en/map.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"suggestNewPointDialogTitle": "Suggest a New Point",
"yourPosition": "Your Position",
"fileTooLarge": "The selected file is too large. Please select a file smaller than 5MB.",
"photoProcessingFailed": "Couldn't process this photo. Please try a different file.",
"locationNotAvailable": "Location not available. Please enable location services and try again.",
"fillRequiredFields": "Please fill in required fields: {{fields}}",
"locationSuggestedSuccess": "Location suggested successfully!",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/pl/map.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"suggestNewPointDialogTitle": "Zaproponuj nowy punkt",
"yourPosition": "Twoja pozycja",
"fileTooLarge": "Wybrany plik jest za duży. Wybierz plik mniejszy niż 5MB.",
"photoProcessingFailed": "Nie udało się przetworzyć tego zdjęcia. Spróbuj wybrać inny plik.",
"locationNotAvailable": "Lokalizacja niedostępna. Włącz usługi lokalizacji i spróbuj ponownie.",
"fillRequiredFields": "Wypełnij wymagane pola: {{fields}}",
"locationSuggestedSuccess": "Lokalizacja została zaproponowana pomyślnie!",
Expand Down
1 change: 1 addition & 0 deletions frontend/src/locales/ua/map.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
"suggestNewPointDialogTitle": "Запропонувати нову точку",
"yourPosition": "Ваша позиція",
"fileTooLarge": "Вибраний файл завеликий. Виберіть файл розміром менше 5МБ.",
"photoProcessingFailed": "Не вдалося обробити це фото. Спробуйте вибрати інший файл.",
"locationNotAvailable": "Місцезнаходження недоступне. Увімкніть служби геолокації та спробуйте знову.",
"fillRequiredFields": "Заповніть обов'язкові поля: {{fields}}",
"locationSuggestedSuccess": "Локацію успішно запропоновано!",
Expand Down
65 changes: 65 additions & 0 deletions frontend/src/utils/imageCompression.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Only the backend's allowed extensions are worth targeting (jpeg/jpg), so we
// always re-encode to JPEG regardless of the source format.
const MIN_QUALITY = 0.4;
const QUALITY_STEP = 0.1;

const loadImage = file =>
new Promise((resolve, reject) => {
const objectUrl = URL.createObjectURL(file);
const img = new Image();
img.onload = () => {
URL.revokeObjectURL(objectUrl);
resolve(img);
};
img.onerror = () => {
URL.revokeObjectURL(objectUrl);
reject(new Error('Failed to load image for compression'));
};
img.src = objectUrl;
});

const canvasToBlob = (canvas, quality) =>
new Promise((resolve, reject) => {
canvas.toBlob(
blob => (blob ? resolve(blob) : reject(new Error('Image encoding failed'))),
'image/jpeg',
quality,
);
});

/**
* Re-encodes an image file as JPEG, scaling it down to fit within maxDimension
* and lowering quality as needed to land under maxSizeBytes.
*
* @param {File} file - Source image file.
* @param {{maxSizeBytes?: number, maxDimension?: number}} options
* @returns {Promise<File>} Compressed JPEG file (may still exceed maxSizeBytes
* if the image can't be reduced further without going below MIN_QUALITY).
*/
export const compressImageToJpeg = async (file, { maxSizeBytes, maxDimension = 1920 } = {}) => {
const img = await loadImage(file);

const scale = Math.min(1, maxDimension / Math.max(img.width, img.height));
const width = Math.round(img.width * scale);
const height = Math.round(img.height * scale);

const canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
const context = canvas.getContext('2d');
// JPEG has no alpha channel; without this, transparent PNG areas default to
// black (canvas pixels start as rgba(0,0,0,0), and the alpha just gets dropped).
context.fillStyle = '#fff';
context.fillRect(0, 0, width, height);
context.drawImage(img, 0, 0, width, height);

let quality = 0.9;
let blob = await canvasToBlob(canvas, quality);
while (blob.size > maxSizeBytes && quality > MIN_QUALITY) {
quality -= QUALITY_STEP;
blob = await canvasToBlob(canvas, quality);
}

const baseName = file.name.replace(/\.[^./\\]+$/, '') || 'photo';
return new File([blob], `${baseName}.jpg`, { type: 'image/jpeg' });
};
Loading
Loading