loko/streetup/assets/static/assets/green-surfaces-comments.js
2026-07-22 14:48:40 +02:00

2435 lines
98 KiB
JavaScript

/**
* Green Surfaces Comments Component
*
* Manages geolocated comments with photo capture for the green surfaces editing interface.
* Integrates camera functionality similar to observations app.
*/
// Translation function alias
const _ = gettext;
/**
* InfoPanel class - manages the information panel overlay
* Uses the existing attributes-panel for mobile interface
*/
class InfoPanel {
constructor() {
// Use attributes-panel and its content containers
this.panel = document.getElementById('attributes-panel');
this.commentContent = document.getElementById('comment-panel-content');
this.defaultContent = document.getElementById('default-panel-content');
this.panelTitle = document.getElementById('panel-title');
this.selectionCount = document.getElementById('selection-count');
// Store original title
this.originalTitle = this.panelTitle ? this.panelTitle.textContent : '';
this.history = [];
this.isShowingComment = false;
this.onCloseCallback = null;
}
setOnCloseCallback(callback) {
this.onCloseCallback = callback;
}
show(title, content, addToHistory = true) {
if (!this.panel || !this.commentContent) {
return;
}
// Save history if needed
if (addToHistory && this.isShowingComment) {
this.history.push({
title: this.panelTitle.textContent,
content: this.commentContent.innerHTML
});
}
// Update title
if (this.panelTitle) {
this.panelTitle.textContent = title;
}
// Hide selection count
if (this.selectionCount) {
this.selectionCount.style.display = 'none';
}
// Set content
if (typeof content === 'string') {
this.commentContent.innerHTML = content;
} else {
this.commentContent.innerHTML = '';
this.commentContent.appendChild(content);
}
// Show comment content, hide default content
this.commentContent.style.display = 'block';
if (this.defaultContent) {
this.defaultContent.style.display = 'none';
}
// Expand panel
this.panel.classList.remove('collapsed');
this.isShowingComment = true;
// Add close button to content if not present
this.addCloseButton();
}
addCloseButton() {
// Check if close button already exists
if (!this.commentContent.querySelector('.comment-close-btn')) {
const closeBtn = document.createElement('button');
closeBtn.className = 'btn btn-sm btn-outline-secondary comment-close-btn mb-3';
closeBtn.innerHTML = '<i class="bi bi-arrow-left"></i> Retour';
closeBtn.addEventListener('click', () => this.close());
this.commentContent.insertBefore(closeBtn, this.commentContent.firstChild);
}
}
updateContent(content) {
if (!this.commentContent) return;
if (typeof content === 'string') {
this.commentContent.innerHTML = content;
} else {
this.commentContent.innerHTML = '';
this.commentContent.appendChild(content);
}
this.addCloseButton();
}
goBack() {
if (this.history.length > 0) {
const previous = this.history.pop();
this.show(previous.title, previous.content, false);
} else {
this.close();
}
}
close() {
if (!this.panel) return;
// Hide comment content, show default content
if (this.commentContent) {
this.commentContent.style.display = 'none';
this.commentContent.innerHTML = '';
}
if (this.defaultContent) {
this.defaultContent.style.display = 'block';
}
// Restore original title
if (this.panelTitle) {
this.panelTitle.textContent = this.originalTitle;
}
// Show selection count
if (this.selectionCount) {
this.selectionCount.style.display = '';
}
this.history = [];
this.isShowingComment = false;
// Call close callback if set
if (this.onCloseCallback) {
this.onCloseCallback();
}
}
isOpen() {
return this.isShowingComment;
}
}
class GreenSurfacesComments {
constructor(map, options = {}) {
this.map = map;
this.options = {
apiBaseUrl: options.apiBaseUrl || '/assets/nature/comments',
...options
};
this.currentLocation = null;
this.lastGpsLocation = null; // Store last real GPS position (not manually set)
this.currentHeading = null;
this.capturedPhotos = [];
this.cameraStream = null;
this.commentModal = null;
this.cameraModal = null;
this.mapLayersInitialized = false;
this.locationManuallySet = false; // Flag to prevent GPS updates when user manually repositions marker
// Initialize asynchronously to ensure map is fully ready
setTimeout(() => this.init(), 0);
}
init() {
// Add map control immediately (MapLibre handles this even before 'load')
this.addMapControl();
// Don't try to initialize info panel immediately - it will be done when needed
// this.initInfoPanel();
this.setupMapSource();
this.setupUI();
this.setupEventListeners();
this.watchLocation();
}
/**
* Initialize info panel if it doesn't exist
*/
initInfoPanel() {
if (!window.infoPanel) {
const panel = document.getElementById('attributes-panel');
const commentContent = document.getElementById('comment-panel-content');
if (!panel || !commentContent) {
return false;
}
try {
window.infoPanel = new InfoPanel();
} catch (error) {
return false;
}
}
// Always set close callback to clean up marker (use window.commentsComponent for reliable reference)
window.infoPanel.setOnCloseCallback(() => {
if (window.commentsComponent) {
window.commentsComponent.cleanupRepositionMarker();
}
});
return true;
}
/**
* Add custom control button to map toolbar
*/
addMapControl() {
if (!this.map) return;
// Track layer visibility
this.commentsLayerVisible = true;
const control = {
onAdd: (map) => {
this.controlContainer = document.createElement('div');
this.controlContainer.className = 'maplibregl-ctrl maplibregl-ctrl-group';
// Button to add comment
const addButton = document.createElement('button');
addButton.type = 'button';
addButton.className = 'maplibregl-ctrl-icon';
addButton.setAttribute('aria-label', 'Ajouter un commentaire');
addButton.title = 'Ajouter un commentaire géolocalisé';
// Add comment icon (chat-dots from Bootstrap Icons)
addButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
<path d="M5 8a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm4 0a1 1 0 1 1-2 0 1 1 0 0 1 2 0zm3 1a1 1 0 1 0 0-2 1 1 0 0 0 0 2z"/>
<path d="m2.165 15.803.02-.004c1.83-.363 2.948-.842 3.468-1.105A9.06 9.06 0 0 0 8 15c4.418 0 8-3.134 8-7s-3.582-7-8-7-8 3.134-8 7c0 1.76.743 3.37 1.97 4.6a10.437 10.437 0 0 1-.524 2.318l-.003.011a10.722 10.722 0 0 1-.244.637c-.079.186.074.394.273.362a21.673 21.673 0 0 0 .693-.125zm.8-3.108a1 1 0 0 0-.287-.801C1.618 10.83 1 9.468 1 8c0-3.192 3.004-6 7-6s7 2.808 7 6c0 3.193-3.004 6-7 6a8.06 8.06 0 0 1-2.088-.272 1 1 0 0 0-.711.074c-.387.196-1.24.57-2.634.893a10.97 10.97 0 0 0 .398-2z"/>
</svg>
`;
addButton.style.width = '42px';
addButton.style.height = '42px';
addButton.style.display = 'flex';
addButton.style.alignItems = 'center';
addButton.style.justifyContent = 'center';
addButton.addEventListener('click', () => {
this.openCommentPanel();
});
// Button to toggle layer visibility
const toggleButton = document.createElement('button');
toggleButton.type = 'button';
toggleButton.className = 'maplibregl-ctrl-icon';
toggleButton.setAttribute('aria-label', 'Afficher/masquer les commentaires');
toggleButton.title = 'Afficher/masquer la couche des commentaires';
toggleButton.id = 'comments-layer-toggle';
// Eye icon (visible by default)
toggleButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/>
<path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/>
</svg>
`;
toggleButton.style.width = '42px';
toggleButton.style.height = '42px';
toggleButton.style.display = 'flex';
toggleButton.style.alignItems = 'center';
toggleButton.style.justifyContent = 'center';
toggleButton.addEventListener('click', () => {
this.toggleCommentsLayer();
});
this.controlContainer.appendChild(addButton);
this.controlContainer.appendChild(toggleButton);
return this.controlContainer;
},
onRemove: () => {
if (this.controlContainer && this.controlContainer.parentNode) {
this.controlContainer.parentNode.removeChild(this.controlContainer);
}
}
};
this.map.addControl(control, 'top-left');
}
/**
* Toggle comments layer visibility
*/
toggleCommentsLayer() {
if (!this.map) return;
this.commentsLayerVisible = !this.commentsLayerVisible;
const visibility = this.commentsLayerVisible ? 'visible' : 'none';
// Toggle layer and labels
if (this.map.getLayer('green-comments-layer')) {
this.map.setLayoutProperty('green-comments-layer', 'visibility', visibility);
}
if (this.map.getLayer('green-comments-labels')) {
this.map.setLayoutProperty('green-comments-labels', 'visibility', visibility);
}
// Update button icon
const toggleButton = document.getElementById('comments-layer-toggle');
if (toggleButton) {
if (this.commentsLayerVisible) {
// Eye icon (visible)
toggleButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
<path d="M16 8s-3-5.5-8-5.5S0 8 0 8s3 5.5 8 5.5S16 8 16 8zM1.173 8a13.133 13.133 0 0 1 1.66-2.043C4.12 4.668 5.88 3.5 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.133 13.133 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755C11.879 11.332 10.119 12.5 8 12.5c-2.12 0-3.879-1.168-5.168-2.457A13.134 13.134 0 0 1 1.172 8z"/>
<path d="M8 5.5a2.5 2.5 0 1 0 0 5 2.5 2.5 0 0 0 0-5zM4.5 8a3.5 3.5 0 1 1 7 0 3.5 3.5 0 0 1-7 0z"/>
</svg>
`;
toggleButton.title = 'Masquer la couche des commentaires';
} else {
// Eye-slash icon (hidden)
toggleButton.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="currentColor" viewBox="0 0 16 16">
<path d="M13.359 11.238C15.06 9.72 16 8 16 8s-3-5.5-8-5.5a7.028 7.028 0 0 0-2.79.588l.77.771A5.944 5.944 0 0 1 8 3.5c2.12 0 3.879 1.168 5.168 2.457A13.134 13.134 0 0 1 14.828 8c-.058.087-.122.183-.195.288-.335.48-.83 1.12-1.465 1.755-.165.165-.337.328-.517.486l.708.709z"/>
<path d="M11.297 9.176a3.5 3.5 0 0 0-4.474-4.474l.823.823a2.5 2.5 0 0 1 2.829 2.829l.822.822zm-2.943 1.299.822.822a3.5 3.5 0 0 1-4.474-4.474l.823.823a2.5 2.5 0 0 0 2.829 2.829z"/>
<path d="M3.35 5.47c-.18.16-.353.322-.518.487A13.134 13.134 0 0 0 1.172 8l.195.288c.335.48.83 1.12 1.465 1.755C4.121 11.332 5.881 12.5 8 12.5c.716 0 1.39-.133 2.02-.36l.77.772A7.029 7.029 0 0 1 8 13.5C3 13.5 0 8 0 8s.939-1.721 2.641-3.238l.708.709zm10.296 8.884-12-12 .708-.708 12 12-.708.708z"/>
</svg>
`;
toggleButton.title = 'Afficher la couche des commentaires';
}
}
}
/**
* Setup map source and layer for displaying comments
*/
setupMapSource() {
if (!this.map) {
return;
}
// Function to initialize source and layers
const initializeMapLayers = () => {
// Check if source already exists
if (this.map.getSource('green-comments')) {
this.mapLayersInitialized = true;
return;
}
// Add source for comments
this.map.addSource('green-comments', {
type: 'geojson',
data: {
type: 'FeatureCollection',
features: []
}
});
// Add layer for comment markers with status-based colors
this.map.addLayer({
id: 'green-comments-layer',
type: 'circle',
source: 'green-comments',
paint: {
'circle-radius': [
'interpolate',
['linear'],
['zoom'],
10, 2, // zoom 10: radius 2px
13, 4, // zoom 13: radius 4px
15, 5, // zoom 15: radius 6px
18, 6, // zoom 18: radius 10px
20, 8, // zoom 20: radius 12px
22, 10 // zoom 22: radius 14px
],
'circle-color': '#ffffff', // White fill
'circle-stroke-width': [
'interpolate',
['linear'],
['zoom'],
10, 1, // zoom 10: stroke 1px
15, 1, // zoom 15: stroke 1px
18, 2 // zoom 18: stroke 2px
],
'circle-stroke-color': [
'match',
['get', 'status'],
'memo', '#0d6efd', // Primary blue
'attention', '#ffc107', // Warning yellow
'todo', '#0dcaf0', // Info cyan
'ok', '#198754', // Success green
'problem', '#dc3545', // Danger red
'#0d6efd' // Default: blue
]
}
});
// Add labels
this.map.addLayer({
id: 'green-comments-labels',
type: 'symbol',
source: 'green-comments',
layout: {
'text-field': ['to-string', ['get', 'photo_count']],
'text-font': ['Noto Sans Regular'],
'text-size': 10,
'text-offset': [0, 0],
'text-allow-overlap': true,
'text-ignore-placement': true
},
paint: {
'text-color': '#222222'
}
});
// Click handler for comments
this.map.on('click', 'green-comments-layer', (e) => {
if (e.features.length > 0) {
this.showCommentDetail(e.features[0].properties.id);
}
});
// Change cursor on hover
this.map.on('mouseenter', 'green-comments-layer', () => {
this.map.getCanvas().style.cursor = 'pointer';
});
this.map.on('mouseleave', 'green-comments-layer', () => {
this.map.getCanvas().style.cursor = '';
});
// Mark as initialized
this.mapLayersInitialized = true;
// Setup moveend listener AFTER initialization
this.map.on('moveend', () => {
this.loadComments();
});
// Load comments
this.loadComments();
};
// Use 'idle' event which fires when map is truly ready
// This works even if the map is already loaded
this.map.once('idle', () => {
initializeMapLayers();
});
}
/**
* Setup UI elements
*/
setupUI() {
// Create comment modal
this.createCommentModal();
// Create camera modal (similar to observations)
this.createCameraModal();
}
/**
* Create the comment creation modal
*/
createCommentModal() {
const modalHTML = `
<div class="modal fade" id="commentModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-fullscreen-sm-down">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-chat-dots"></i> <span id="modal-title-text">${_('Nouveau commentaire')}</span>
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<!-- Quick text badges -->
<div class="mb-3">
<label class="form-label">${_('Textes rapides')}</label>
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-sm btn-outline-primary quick-text-badge" data-text="${_('Mémo')}">
<i class="bi bi-bookmark-fill me-1"></i>${_('Mémo')}
</button>
<button type="button" class="btn btn-sm btn-outline-warning quick-text-badge" data-text="${_('Point d\'attention')}">
<i class="bi bi-exclamation-triangle-fill me-1"></i>${_('Point d\'attention')}
</button>
<button type="button" class="btn btn-sm btn-outline-info quick-text-badge" data-text="${_('À faire')}">
<i class="bi bi-check-circle-fill me-1"></i>${_('À faire')}
</button>
<button type="button" class="btn btn-sm btn-outline-success quick-text-badge" data-text="${_('OK')}">
<i class="bi bi-hand-thumbs-up-fill me-1"></i>${_('OK')}
</button>
<button type="button" class="btn btn-sm btn-outline-danger quick-text-badge" data-text="${_('Problème')}">
<i class="bi bi-x-circle-fill me-1"></i>${_('Problème')}
</button>
</div>
</div>
<div class="mb-3">
<label class="form-label">${_('Commentaire')} <span class="text-danger">*</span></label>
<textarea id="comment-text" class="form-control" rows="4" placeholder="${_('Votre commentaire...')}"></textarea>
</div>
<div class="mb-3">
<label class="form-label">${_('Photos')}</label>
<div class="d-grid gap-2">
<button type="button" id="open-comment-camera-btn" class="btn btn-outline-primary">
<i class="bi bi-camera-fill me-2"></i>${_('Prendre des photos (mode continu)')}
</button>
<button type="button" id="select-comment-files-btn" class="btn btn-outline-primary">
<i class="bi bi-folder2-open me-2"></i>${_('Sélectionner des fichiers')}
</button>
</div>
<input type="file" id="comment-files-input" accept="image/*" multiple class="d-none">
</div>
<div id="comment-photos-preview" class="row g-2 mb-3"></div>
<div class="mb-3">
<label class="form-label">${_('Position')}</label>
<div class="d-flex justify-content-between align-items-center">
<div class="form-text">
<i class="bi bi-geo-alt-fill"></i>
<span id="comment-location-text">${_('Position actuelle sera utilisée')}</span>
</div>
<button type="button" id="reposition-comment-btn" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-cursor-fill me-1"></i>${_('Repositionner')}
</button>
</div>
</div>
<div id="comment-message" class="alert" style="display: none;"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">${_('Annuler')}</button>
<button type="button" id="save-comment-btn" class="btn btn-primary">
<i class="bi bi-save me-1"></i>${_('Enregistrer')}
</button>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
this.commentModal = new bootstrap.Modal(document.getElementById('commentModal'));
// Setup quick text badges
document.querySelectorAll('.quick-text-badge').forEach(badge => {
badge.addEventListener('click', (e) => {
const text = e.currentTarget.dataset.text;
const textarea = document.getElementById('comment-text');
textarea.value = text;
textarea.focus();
});
});
}
/**
* Create the camera modal (similar to observations app)
*/
createCameraModal() {
const modalHTML = `
<div class="modal fade" id="commentCameraModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-fullscreen-sm-down modal-dialog-centered">
<div class="modal-content bg-dark text-white">
<div class="modal-header border-0">
<h5 class="modal-title">
<i class="bi bi-camera-video"></i> ${_('Caméra')}
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" id="comment-camera-close-btn"></button>
</div>
<div class="modal-body d-flex flex-column justify-content-between p-2">
<div class="text-center mb-2">
<div class="position-relative" style="border-radius: 0.5rem; overflow: hidden;">
<video id="comment-camera-preview" class="w-100" playsinline autoplay></video>
<div id="comment-camera-flash" style="position: absolute; inset: 0; background: rgba(255, 255, 255, 0.9); opacity: 0; pointer-events: none; transition: opacity 120ms ease-out; z-index: 2;"></div>
<canvas id="comment-camera-canvas" class="d-none"></canvas>
</div>
</div>
<div id="comment-camera-status" class="small text-center text-muted mb-2"></div>
<div class="d-flex justify-content-between align-items-center gap-2 mb-3">
<button type="button" class="btn btn-outline-light flex-fill" id="comment-camera-stop-btn">
<i class="bi bi-x-circle"></i> ${_('Terminer')}
</button>
<button type="button" class="btn btn-primary flex-fill" id="comment-camera-capture-btn">
<i class="bi bi-camera-fill"></i> ${_('Capturer')}
</button>
</div>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
this.cameraModal = new bootstrap.Modal(document.getElementById('commentCameraModal'));
}
/**
* Setup event listeners
*/
setupEventListeners() {
// Camera button
document.getElementById('open-comment-camera-btn')?.addEventListener('click', async () => {
const ok = await this.startCamera();
if (ok) {
this.cameraModal.show();
}
});
// File select button
document.getElementById('select-comment-files-btn')?.addEventListener('click', () => {
document.getElementById('comment-files-input')?.click();
});
// File input change
document.getElementById('comment-files-input')?.addEventListener('change', (e) => {
const files = Array.from(e.target.files);
files.forEach(file => this.addPhotoToPreview(file));
e.target.value = '';
});
// Camera capture
document.getElementById('comment-camera-capture-btn')?.addEventListener('click', () => {
this.triggerFlash();
this.capturePhoto();
});
// Camera stop
document.getElementById('comment-camera-stop-btn')?.addEventListener('click', () => {
this.cameraModal.hide();
});
// Camera modal close
document.getElementById('commentCameraModal')?.addEventListener('hidden.bs.modal', () => {
this.stopCamera();
});
// Save comment
document.getElementById('save-comment-btn')?.addEventListener('click', () => {
this.saveComment();
});
// Reposition button
document.getElementById('reposition-comment-btn')?.addEventListener('click', () => {
this.startRepositionMode();
});
// Comment modal close - reset state (but not during repositioning)
document.getElementById('commentModal')?.addEventListener('hidden.bs.modal', () => {
if (!this.isRepositioning) {
this.resetCommentForm();
}
});
}
/**
* Watch user location and heading
*/
watchLocation() {
if (navigator.geolocation) {
navigator.geolocation.watchPosition(
(position) => {
// Always store real GPS position
this.lastGpsLocation = {
latitude: position.coords.latitude,
longitude: position.coords.longitude
};
// Skip updating currentLocation if user manually positioned the marker
if (this.locationManuallySet) {
return;
}
this.currentLocation = {
latitude: position.coords.latitude,
longitude: position.coords.longitude
};
// Update location text
const locationText = document.getElementById('comment-location-text');
if (locationText) {
locationText.textContent = `${position.coords.latitude.toFixed(6)}, ${position.coords.longitude.toFixed(6)}`;
}
},
(error) => {
// Silent fail
}
);
}
// Watch device orientation for heading
if (window.DeviceOrientationEvent) {
window.addEventListener('deviceorientation', (event) => {
if (event.alpha !== null) {
this.currentHeading = event.alpha;
}
});
}
}
/**
* Start camera stream
*/
async startCamera() {
const preview = document.getElementById('comment-camera-preview');
const status = document.getElementById('comment-camera-status');
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
alert("La caméra n'est pas supportée sur ce navigateur.");
return false;
}
try {
status.textContent = 'Initialisation de la caméra…';
this.cameraStream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1920 },
height: { ideal: 1080 }
},
audio: false
});
if ('srcObject' in preview) {
preview.srcObject = this.cameraStream;
} else {
preview.src = window.URL.createObjectURL(this.cameraStream);
}
preview.play();
status.textContent = 'Caméra prête. Appuyez sur Capturer pour prendre une photo.';
return true;
} catch (err) {
alert("Impossible d'accéder à la caméra.");
status.textContent = "Impossible d'accéder à la caméra.";
return false;
}
}
/**
* Stop camera stream
*/
stopCamera() {
if (this.cameraStream) {
this.cameraStream.getTracks().forEach(track => track.stop());
this.cameraStream = null;
}
const preview = document.getElementById('comment-camera-preview');
if (preview) {
preview.srcObject = null;
}
}
/**
* Trigger camera flash effect
*/
triggerFlash() {
const flash = document.getElementById('comment-camera-flash');
if (!flash) return;
flash.style.opacity = '1';
setTimeout(() => {
flash.style.opacity = '0';
}, 150);
}
/**
* Capture photo from camera
*/
capturePhoto() {
const preview = document.getElementById('comment-camera-preview');
const canvas = document.getElementById('comment-camera-canvas');
const status = document.getElementById('comment-camera-status');
if (!preview || !canvas) return;
const videoWidth = preview.videoWidth;
const videoHeight = preview.videoHeight;
if (!videoWidth || !videoHeight) {
status.textContent = "La vidéo n'est pas encore prête.";
return;
}
canvas.width = videoWidth;
canvas.height = videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(preview, 0, 0, videoWidth, videoHeight);
canvas.toBlob((blob) => {
if (!blob) {
status.textContent = "Erreur lors de la capture.";
return;
}
const filename = `comment_photo_${Date.now()}.jpg`;
const file = new File([blob], filename, { type: 'image/jpeg' });
// Store heading with photo
const photoData = {
file: file,
heading: this.currentHeading
};
this.addPhotoToPreview(photoData);
status.textContent = 'Photo capturée avec succès.';
}, 'image/jpeg', 0.9);
}
/**
* Add photo to preview
*/
addPhotoToPreview(photoData) {
const file = photoData.file || photoData;
const heading = photoData.heading;
// Store photo with metadata
this.capturedPhotos.push({ file, heading });
const preview = document.getElementById('comment-photos-preview');
if (!preview) return;
const reader = new FileReader();
reader.onload = (e) => {
const photoIndex = this.capturedPhotos.length - 1;
const col = document.createElement('div');
col.className = 'col-4';
col.innerHTML = `
<div class="position-relative">
<img src="${e.target.result}" class="img-fluid rounded" alt="Photo">
<button type="button" class="btn btn-sm btn-danger position-absolute top-0 end-0 m-1"
data-photo-index="${photoIndex}">
<i class="bi bi-x"></i>
</button>
${heading !== null && heading !== undefined ? `
<span class="badge bg-dark position-absolute bottom-0 start-0 m-1">
<i class="bi bi-compass"></i> ${Math.round(heading)}°
</span>
` : ''}
</div>
`;
// Remove button handler
col.querySelector('button').addEventListener('click', (e) => {
const index = parseInt(e.currentTarget.dataset.photoIndex);
this.capturedPhotos.splice(index, 1);
col.remove();
});
preview.appendChild(col);
};
reader.readAsDataURL(file);
}
/**
* Open comment modal
*/
openCommentModal() {
if (!this.currentLocation) {
alert('Position non disponible. Veuillez activer la géolocalisation.');
return;
}
this.commentModal.show();
}
/**
* Open comment panel (replaces modal)
*/
openCommentPanel() {
// Clean up any existing repositioning state first (handles double-click on add button)
this.cleanupRepositionMarker();
if (!this.currentLocation) {
alert('Position non disponible. Veuillez activer la géolocalisation.');
return;
}
// Ensure info panel exists
if (!this.initInfoPanel()) {
alert('Le panneau d\'information n\'est pas disponible. Veuillez recharger la page.');
return;
}
// Reset editing state
this.editingCommentId = null;
this.editingCommentData = null;
this.existingPhotosToDelete = [];
this.capturedPhotos = [];
this.locationManuallySet = false; // Reset flag so GPS updates work initially
// Reset currentLocation to real GPS position (not last marker position)
if (this.lastGpsLocation) {
this.currentLocation = {...this.lastGpsLocation};
}
const content = this.generateCommentFormHTML();
window.infoPanel.show(_('Nouveau commentaire'), content);
// Setup event listeners after content is added to DOM
this.setupFormEventListeners();
// Update location text
const locationText = document.getElementById('comment-location-text');
if (locationText && this.currentLocation) {
locationText.textContent = `${this.currentLocation.latitude.toFixed(6)}, ${this.currentLocation.longitude.toFixed(6)}`;
}
// Immediately show marker at current position and enable repositioning
this.startRepositionModeOnOpen();
}
/**
* Start reposition mode immediately when opening new comment
* Shows marker at current GPS position and allows dragging
*/
startRepositionModeOnOpen() {
// Save original location for cancel
this.originalLocation = {...this.currentLocation};
this.isRepositioning = true;
// Lock the location so GPS updates don't change it while marker is shown
this.locationManuallySet = true;
// Create marker at current position
this.createRepositionMarker();
// Set up map click handler for repositioning
this.repositionClickHandler = (e) => {
const { lng, lat } = e.lngLat;
this.updateRepositionMarker(lng, lat);
};
this.map.on('click', this.repositionClickHandler);
// Center map on marker
this.map.flyTo({
center: [this.currentLocation.longitude, this.currentLocation.latitude],
zoom: Math.max(this.map.getZoom(), 17)
});
}
/**
* Generate comment form HTML
*/
generateCommentFormHTML() {
return `
<div class="mb-3">
<strong>${_('Statut')}:</strong>
<div class="d-flex flex-wrap gap-2 mt-2">
<button type="button" class="btn btn-sm btn-primary status-badge active" data-status="memo">
<i class="bi bi-sticky"></i> ${_('Mémo')}
</button>
<button type="button" class="btn btn-sm btn-outline-warning status-badge" data-status="attention">
<i class="bi bi-exclamation-triangle"></i> ${_('Attention')}
</button>
<button type="button" class="btn btn-sm btn-outline-info status-badge" data-status="todo">
<i class="bi bi-list-check"></i> ${_('À faire')}
</button>
</div>
<div class="d-flex flex-wrap gap-2 mt-2">
<button type="button" class="btn btn-sm btn-outline-success status-badge" data-status="ok">
<i class="bi bi-check-circle"></i> ${_('OK')}
</button>
<button type="button" class="btn btn-sm btn-outline-danger status-badge" data-status="problem">
<i class="bi bi-x-circle"></i> ${_('Problème')}
</button>
</div>
<input type="hidden" id="comment-status" value="memo">
</div>
<div class="mb-3">
<label for="comment-text" class="form-label">${_('Commentaire')} <span class="text-muted">${_('(optionnel)')}</span></label>
<textarea id="comment-text" class="form-control" rows="3" placeholder="${_('Entrez un commentaire optionnel...')}"></textarea>
</div>
<div class="mb-3">
<strong>${_('Localisation')}:</strong>
<p id="comment-location-text" class="text-muted small mb-2">${_('Calcul en cours...')}</p>
<button type="button" id="reposition-comment-btn" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-geo-alt"></i> ${_('Repositionner')}
</button>
</div>
<div class="mb-3">
<strong>${_('Photos')}:</strong>
<div class="photo-capture-buttons d-flex gap-2 mt-2">
<button type="button" id="open-camera-btn" class="btn btn-sm btn-primary">
<i class="bi bi-camera"></i> ${_('Appareil photo')}
</button>
<button type="button" id="select-files-btn" class="btn btn-sm btn-secondary">
<i class="bi bi-folder2-open"></i> ${_('Sélectionner fichiers')}
</button>
<input type="file" id="photo-file-input" accept="image/*" multiple style="display: none;">
</div>
<div id="photos-preview" class="row g-2 mt-3"></div>
</div>
<div class="d-flex gap-2">
<button type="button" id="save-comment-btn" class="btn btn-success flex-grow-1">
${_('Enregistrer')}
</button>
<button type="button" id="cancel-comment-btn" class="btn btn-secondary">
${_('Annuler')}
</button>
</div>
`;
}
/**
* Setup event listeners for form elements in panel
*/
setupFormEventListeners() {
// Status badges
const statusColors = {
'memo': 'primary',
'attention': 'warning',
'todo': 'info',
'ok': 'success',
'problem': 'danger'
};
document.querySelectorAll('.status-badge').forEach(badge => {
badge.addEventListener('click', (e) => {
const status = e.currentTarget.dataset.status;
const statusInput = document.getElementById('comment-status');
if (statusInput) {
statusInput.value = status;
}
// Update button styles
document.querySelectorAll('.status-badge').forEach(btn => {
const btnStatus = btn.dataset.status;
const color = statusColors[btnStatus];
btn.className = `btn btn-sm ${btnStatus === status ? `btn-${color}` : `btn-outline-${color}`} status-badge${btnStatus === status ? ' active' : ''}`;
});
});
});
// Reposition button
const repositionBtn = document.getElementById('reposition-comment-btn');
if (repositionBtn) {
repositionBtn.addEventListener('click', () => {
this.startRepositionMode();
});
}
// Camera button
const cameraBtn = document.getElementById('open-camera-btn');
if (cameraBtn) {
cameraBtn.addEventListener('click', () => {
this.openCameraPanel();
});
}
// File selection button
const selectFilesBtn = document.getElementById('select-files-btn');
const fileInput = document.getElementById('photo-file-input');
if (selectFilesBtn && fileInput) {
selectFilesBtn.addEventListener('click', () => {
fileInput.click();
});
fileInput.addEventListener('change', (e) => {
const files = Array.from(e.target.files);
files.forEach(file => {
this.addPhotoFromFile(file);
});
fileInput.value = ''; // Reset input
});
}
// Save button
const saveBtn = document.getElementById('save-comment-btn');
if (saveBtn) {
saveBtn.addEventListener('click', () => {
this.saveComment();
});
}
// Cancel button
const cancelBtn = document.getElementById('cancel-comment-btn');
if (cancelBtn) {
cancelBtn.addEventListener('click', () => {
window.infoPanel.close();
});
}
}
/**
* Add photo from file (without camera)
*/
addPhotoFromFile(file) {
const reader = new FileReader();
reader.onload = (e) => {
const photoData = {
dataUrl: e.target.result,
file: file,
heading: this.currentHeading
};
this.capturedPhotos.push(photoData);
this.updatePhotosPreview();
};
reader.readAsDataURL(file);
}
/**
* Open camera panel for photo capture
*/
async openCameraPanel() {
// Create fullscreen camera modal overlay
const modalHtml = `
<div id="camera-modal-overlay" style="position: fixed; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0,0,0,0.95); z-index: 9999; display: flex; flex-direction: column;">
<div style="flex: 1; display: flex; flex-direction: column; padding: 20px; padding-bottom: 80px;">
<div style="flex: 1; display: flex; align-items: center; justify-content: center; overflow: hidden; margin-bottom: 60px;">
<video id="camera-video" autoplay playsinline style="max-width: 100%; max-height: 100%; width: auto; height: auto; border-radius: 12px;"></video>
</div>
<div class="d-flex justify-content-center gap-3" style="position: absolute; bottom: 60px; left: 0; right: 0; padding: 0 20px;">
<button type="button" id="capture-photo-btn" class="btn btn-primary btn-lg" style="min-width: 150px;">
<i class="bi bi-camera"></i> ${_('Capturer')}
</button>
<button type="button" id="close-camera-btn" class="btn btn-success btn-lg" style="min-width: 150px;">
<i class="bi bi-check"></i> ${_('Terminer')}
</button>
</div>
</div>
</div>
`;
// Add modal to body
document.body.insertAdjacentHTML('beforeend', modalHtml);
// Start camera
try {
const stream = await navigator.mediaDevices.getUserMedia({
video: {
facingMode: 'environment',
width: { ideal: 1920 },
height: { ideal: 1080 }
}
});
const video = document.getElementById('camera-video');
if (video) {
video.srcObject = stream;
this.cameraStream = stream;
}
// Setup button handlers
document.getElementById('capture-photo-btn').addEventListener('click', () => {
this.capturePhotoFromPanel();
});
document.getElementById('close-camera-btn').addEventListener('click', () => {
this.closeCameraPanel();
});
} catch (error) {
alert('Erreur d\'accès à la caméra. Veuillez vérifier les permissions.');
this.closeCameraPanel();
}
}
/**
* Capture photo from camera panel
*/
capturePhotoFromPanel() {
const video = document.getElementById('camera-video');
if (!video) return;
const canvas = document.createElement('canvas');
canvas.width = video.videoWidth;
canvas.height = video.videoHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(video, 0, 0);
canvas.toBlob((blob) => {
const file = new File([blob], `photo_${Date.now()}.jpg`, { type: 'image/jpeg' });
const photoData = {
dataUrl: canvas.toDataURL('image/jpeg'),
file: file,
heading: this.currentHeading
};
this.capturedPhotos.push(photoData);
// Show visual feedback that photo was captured (flash effect)
this.showCaptureFlash();
// Update photo counter in camera overlay
this.updateCameraPhotoCount();
// Keep camera open for continuous capture - user closes with "Fermer" button
}, 'image/jpeg');
}
/**
* Show flash effect when capturing photo
*/
showCaptureFlash() {
const overlay = document.getElementById('camera-modal-overlay');
if (!overlay) return;
// Create flash element
const flash = document.createElement('div');
flash.style.cssText = 'position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: white; opacity: 0.8; pointer-events: none; z-index: 10000;';
overlay.appendChild(flash);
// Fade out and remove
setTimeout(() => {
flash.style.transition = 'opacity 200ms ease-out';
flash.style.opacity = '0';
setTimeout(() => flash.remove(), 200);
}, 50);
}
/**
* Update photo counter in camera overlay
*/
updateCameraPhotoCount() {
const overlay = document.getElementById('camera-modal-overlay');
if (!overlay) return;
// Find or create counter element
let counter = document.getElementById('camera-photo-counter');
if (!counter) {
counter = document.createElement('div');
counter.id = 'camera-photo-counter';
counter.style.cssText = 'position: absolute; top: 20px; right: 20px; background: rgba(0,0,0,0.7); color: white; padding: 8px 16px; border-radius: 20px; font-size: 14px; z-index: 10001;';
overlay.appendChild(counter);
}
const count = this.capturedPhotos.length;
counter.innerHTML = `<i class="bi bi-camera-fill me-1"></i> ${count} photo${count > 1 ? 's' : ''}`;
}
/**
* Close camera panel
*/
closeCameraPanel() {
if (this.cameraStream) {
this.cameraStream.getTracks().forEach(track => track.stop());
this.cameraStream = null;
}
// Remove camera modal overlay
const modalOverlay = document.getElementById('camera-modal-overlay');
if (modalOverlay) {
modalOverlay.remove();
}
// Update photos preview
setTimeout(() => {
this.updatePhotosPreview();
}, 100);
}
/**
* Update photos preview in panel
*/
updatePhotosPreview() {
const preview = document.getElementById('photos-preview');
if (!preview) return;
preview.innerHTML = '';
this.capturedPhotos.forEach((photo, index) => {
const col = document.createElement('div');
col.className = 'col-6 col-md-4';
const card = document.createElement('div');
card.className = 'card position-relative';
const img = document.createElement('img');
img.src = photo.dataUrl;
img.className = 'card-img-top';
img.style.height = '120px';
img.style.objectFit = 'cover';
const deleteBtn = document.createElement('button');
deleteBtn.className = 'btn btn-sm btn-danger position-absolute top-0 end-0 m-1';
deleteBtn.innerHTML = '<i class="bi bi-trash"></i>';
deleteBtn.addEventListener('click', () => {
this.capturedPhotos.splice(index, 1);
this.updatePhotosPreview();
});
card.appendChild(img);
card.appendChild(deleteBtn);
// Add heading badge if available
if (photo.heading !== null && photo.heading !== undefined) {
const badge = document.createElement('span');
badge.className = 'badge bg-primary position-absolute bottom-0 start-0 m-1';
badge.innerHTML = `<i class="bi bi-compass"></i> ${Math.round(photo.heading)}°`;
card.appendChild(badge);
}
col.appendChild(card);
preview.appendChild(col);
});
}
/**
* Save comment
*/
async saveComment() {
const commentText = document.getElementById('comment-text')?.value.trim();
const messageDiv = document.getElementById('comment-message');
const saveBtn = document.getElementById('save-comment-btn');
const status = document.getElementById('comment-status')?.value || 'memo';
// Check if we're editing an existing comment
if (this.editingCommentId && this.editingCommentData) {
await this.updateComment(this.editingCommentId);
return;
}
// Check if we're adding photos to existing comment
if (this.editingCommentId && !this.editingCommentData) {
if (this.capturedPhotos.length === 0) {
this.showMessage('Veuillez ajouter au moins une photo.', 'warning');
return;
}
await this.addPhotosToComment(this.editingCommentId);
return;
}
// Normal comment creation - text is now optional
if (!this.currentLocation) {
this.showMessage('Position non disponible.', 'danger');
return;
}
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Enregistrement...';
try {
const formData = new FormData();
formData.append('comment', commentText || '');
formData.append('status', status);
formData.append('latitude', this.currentLocation.latitude);
formData.append('longitude', this.currentLocation.longitude);
// Add photos with their headings
this.capturedPhotos.forEach((photoData, index) => {
formData.append('photos', photoData.file);
if (photoData.heading !== null && photoData.heading !== undefined) {
formData.append('photo_headings', photoData.heading);
} else {
formData.append('photo_headings', '');
}
});
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value || '';
const response = await fetch(`${this.options.apiBaseUrl}/save/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken
},
body: formData
});
const data = await response.json();
if (data.success) {
this.showMessage('Commentaire enregistré avec succès!', 'success');
// Clean up reposition marker
this.cleanupRepositionMarker();
// Close panel and reload
setTimeout(() => {
window.infoPanel.close();
this.loadComments();
}, 800);
} else {
this.showMessage(data.error || 'Erreur lors de l\'enregistrement', 'danger');
}
} catch (error) {
this.showMessage('Erreur lors de l\'enregistrement', 'danger');
} finally {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-save me-1"></i>Enregistrer';
}
}
/**
* Add photos to existing comment
*/
async addPhotosToComment(commentId) {
const saveBtn = document.getElementById('save-comment-btn');
try {
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Ajout...';
const formData = new FormData();
formData.append('comment_id', commentId);
// Add photos with headings
this.capturedPhotos.forEach((photoData, index) => {
formData.append('photos', photoData.file);
if (photoData.heading !== null && photoData.heading !== undefined) {
formData.append('photo_headings', photoData.heading);
} else {
formData.append('photo_headings', '');
}
});
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value || '';
const response = await fetch(`${this.options.apiBaseUrl}/${commentId}/add-photos/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken
},
body: formData
});
const data = await response.json();
if (data.success) {
this.showMessage('Photos ajoutées avec succès!', 'success');
// Reset form
this.resetCommentForm();
// Close modal and reload after a short delay
setTimeout(() => {
this.commentModal.hide();
this.loadComments();
// Reopen detail modal
setTimeout(() => this.showCommentDetail(commentId), 300);
}, 1000);
} else {
this.showMessage(data.error || 'Erreur lors de l\'ajout des photos', 'danger');
}
} catch (error) {
this.showMessage('Erreur lors de l\'ajout des photos', 'danger');
} finally {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-plus-circle me-1"></i>Ajouter les photos';
}
}
/**
* Update existing comment
*/
async updateComment(commentId) {
const commentText = document.getElementById('comment-text')?.value.trim();
const saveBtn = document.getElementById('save-comment-btn');
const status = document.getElementById('comment-status')?.value || 'memo';
// Text is now optional
if (!this.currentLocation) {
this.showMessage('Position non disponible.', 'danger');
return;
}
try {
saveBtn.disabled = true;
saveBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-1"></span>Enregistrement...';
const formData = new FormData();
formData.append('comment_id', commentId);
formData.append('comment', commentText || '');
formData.append('status', status);
formData.append('latitude', this.currentLocation.latitude);
formData.append('longitude', this.currentLocation.longitude);
// Add photos to delete
if (this.existingPhotosToDelete && this.existingPhotosToDelete.length > 0) {
this.existingPhotosToDelete.forEach(photoId => {
formData.append('photos_to_delete', photoId);
});
}
// Add new photos with headings
this.capturedPhotos.forEach((photoData, index) => {
formData.append('photos', photoData.file);
if (photoData.heading !== null && photoData.heading !== undefined) {
formData.append('photo_headings', photoData.heading);
} else {
formData.append('photo_headings', '');
}
});
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value || '';
const response = await fetch(`${this.options.apiBaseUrl}/save/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken
},
body: formData
});
const data = await response.json();
if (data.success) {
this.showMessage('Commentaire mis à jour avec succès!', 'success');
// Close panel and reload
setTimeout(() => {
window.infoPanel.close();
this.loadComments();
// Reopen detail
setTimeout(() => this.showCommentDetail(commentId), 300);
}, 800);
} else {
this.showMessage(data.error || 'Erreur lors de la mise à jour', 'danger');
}
} catch (error) {
this.showMessage('Erreur lors de la mise à jour', 'danger');
} finally {
saveBtn.disabled = false;
saveBtn.innerHTML = '<i class="bi bi-save me-1"></i>Enregistrer les modifications';
}
}
/**
* Reset comment form
*/
resetCommentForm() {
const commentText = document.getElementById('comment-text');
if (commentText) commentText.value = '';
const photosPreview = document.getElementById('comment-photos-preview');
if (photosPreview) photosPreview.innerHTML = '';
this.capturedPhotos = [];
const messageDiv = document.getElementById('comment-message');
if (messageDiv) messageDiv.style.display = 'none';
// Always clean up reposition marker
this.cleanupRepositionMarker();
}
/**
* Clean up reposition marker and related state
* Called on save, cancel, panel close, and form reset
*/
cleanupRepositionMarker() {
// Remove marker from map
if (this.repositionMarker) {
this.repositionMarker.remove();
this.repositionMarker = null;
}
// Clear repositioning flag
this.isRepositioning = false;
// Reset location flag to allow GPS updates again
this.locationManuallySet = false;
// Restore currentLocation to real GPS position for next comment
if (this.lastGpsLocation) {
this.currentLocation = {...this.lastGpsLocation};
}
// Remove click handler if exists
if (this.repositionClickHandler) {
this.map.off('click', this.repositionClickHandler);
this.repositionClickHandler = null;
}
// Remove instructions overlay if exists
const instructions = document.getElementById('reposition-instructions');
if (instructions) {
instructions.remove();
}
}
/**
* Show message in modal
*/
showMessage(message, type) {
// Create toast container if it doesn't exist
let toastContainer = document.getElementById('toast-container');
if (!toastContainer) {
toastContainer = document.createElement('div');
toastContainer.id = 'toast-container';
toastContainer.className = 'toast-container position-fixed top-0 end-0 p-3';
toastContainer.style.zIndex = '9999';
document.body.appendChild(toastContainer);
}
// Create toast
const toastId = `toast-${Date.now()}`;
const toastHTML = `
<div id="${toastId}" class="toast align-items-center text-white bg-${type} border-0" role="alert">
<div class="d-flex">
<div class="toast-body">
${message}
</div>
<button type="button" class="btn-close btn-close-white me-2 m-auto" data-bs-dismiss="toast"></button>
</div>
</div>
`;
toastContainer.insertAdjacentHTML('beforeend', toastHTML);
const toastElement = document.getElementById(toastId);
const toast = new bootstrap.Toast(toastElement, { delay: 3000 });
toast.show();
// Remove toast from DOM after it's hidden
toastElement.addEventListener('hidden.bs.toast', () => {
toastElement.remove();
});
}
/**
* Load comments from server
*/
async loadComments() {
if (!this.map) return;
// Don't try to load if layers aren't initialized yet
if (!this.mapLayersInitialized) {
return;
}
const bounds = this.map.getBounds();
const sw = bounds.getSouthWest();
const ne = bounds.getNorthEast();
const filterGeom = {
geometry: {
type: 'Polygon',
coordinates: [[
[sw.lng, sw.lat],
[ne.lng, sw.lat],
[ne.lng, ne.lat],
[sw.lng, ne.lat],
[sw.lng, sw.lat]
]]
}
};
try {
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value || '';
const response = await fetch(`${this.options.apiBaseUrl}/geojson/`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
body: JSON.stringify({ filter_geom: filterGeom })
});
const data = await response.json();
const source = this.map.getSource('green-comments');
if (source) {
source.setData(data);
} else {
// Try to initialize if not done yet
if (!this.mapLayersInitialized && this.map.loaded()) {
this.setupMapSource();
}
}
} catch (error) {
// Silent fail
}
}
/**
* Show comment detail
*/
async showCommentDetail(commentId) {
try {
const response = await fetch(`${this.options.apiBaseUrl}/${commentId}/`);
const result = await response.json();
if (result.success) {
const comment = result.data;
// Generate photos HTML
let photosHTML = '';
if (comment.photos && comment.photos.length > 0) {
photosHTML = '<div class="row g-2">';
comment.photos.forEach(photo => {
photosHTML += `
<div class="col-6 col-md-4">
<div class="position-relative" style="cursor: pointer;">
<img src="${photo.url}" class="img-fluid rounded mb-2 photo-clickable-detail"
data-photo-url="${photo.url}" alt="Photo"
style="object-fit: cover; height: 150px; width: 100%;">
${photo.heading !== null ? `
<span class="badge bg-dark position-absolute" style="bottom: 8px; left: 4px; font-size: 0.75rem;">
<i class="bi bi-compass"></i> ${Math.round(photo.heading)}°
</span>
` : ''}
</div>
</div>
`;
});
photosHTML += '</div>';
} else {
photosHTML = '<p class="text-muted">Aucune photo</p>';
}
// Get status info
const statusInfo = {
'memo': { label: 'Mémo', color: 'primary', icon: 'bi-sticky' },
'attention': { label: 'Attention', color: 'warning', icon: 'bi-exclamation-triangle' },
'todo': { label: 'À faire', color: 'info', icon: 'bi-list-check' },
'ok': { label: 'OK', color: 'success', icon: 'bi-check-circle' },
'problem': { label: 'Problème', color: 'danger', icon: 'bi-x-circle' }
};
const status = statusInfo[comment.status] || statusInfo['memo'];
// Generate detail content
const content = `
<div class="mb-3">
<strong>Statut:</strong>
<p><span class="badge bg-${status.color}"><i class="bi ${status.icon}"></i> ${status.label}</span></p>
</div>
<div class="mb-3">
<strong>Commentaire:</strong>
<p>${comment.comment || '<span class="text-muted">Aucun commentaire</span>'}</p>
</div>
<div class="mb-3">
<strong>Auteur:</strong>
<p class="text-muted mb-1">${comment.created_by}</p>
<small class="text-muted">${new Date(comment.created_at).toLocaleString('fr-BE')}</small>
</div>
<div class="mb-3">
<strong>Adresse:</strong>
<p class="text-muted">${comment.address || 'N/A'}</p>
</div>
<div class="mb-3">
<strong>Photos:</strong>
${photosHTML}
</div>
${comment.can_edit ? `
<div class="d-flex gap-2">
<button type="button" id="edit-comment-btn-panel" class="btn btn-primary flex-grow-1">
<i class="bi bi-pencil"></i> Modifier
</button>
<button type="button" id="delete-comment-btn-panel" class="btn btn-danger">
<i class="bi bi-trash"></i> Supprimer
</button>
</div>
` : ''}
`;
// Show in panel
if (!this.initInfoPanel()) {
alert('Impossible d\'afficher les détails. Veuillez recharger la page.');
return;
}
window.infoPanel.show('Détails du commentaire', content);
// Setup event listeners
document.querySelectorAll('.photo-clickable-detail').forEach(img => {
img.addEventListener('click', (e) => {
this.openPhotoModal(e.currentTarget.dataset.photoUrl);
});
});
if (comment.can_edit) {
const editBtn = document.getElementById('edit-comment-btn-panel');
if (editBtn) {
editBtn.addEventListener('click', () => {
this.openEditCommentPanel(comment);
});
}
const deleteBtn = document.getElementById('delete-comment-btn-panel');
if (deleteBtn) {
deleteBtn.addEventListener('click', async () => {
if (confirm('Êtes-vous sûr de vouloir supprimer ce commentaire ?')) {
await this.deleteComment(commentId);
}
});
}
}
}
} catch (error) {
window.infoPanel.show('Erreur', '<p class="text-danger">Erreur lors du chargement du commentaire.</p>');
}
}
/**
* Delete comment
*/
async deleteComment(commentId) {
try {
const csrfToken = document.querySelector('[name=csrfmiddlewaretoken]')?.value || '';
const response = await fetch(`${this.options.apiBaseUrl}/${commentId}/delete/`, {
method: 'POST',
headers: {
'X-CSRFToken': csrfToken
}
});
const data = await response.json();
if (data.success) {
this.showMessage('Commentaire supprimé avec succès!', 'success');
window.infoPanel.close();
this.loadComments();
} else {
this.showMessage(data.error || 'Erreur lors de la suppression', 'danger');
}
} catch (error) {
this.showMessage('Erreur lors de la suppression', 'danger');
}
}
/**
* Open edit comment panel (replaces modal)
*/
openEditCommentPanel(comment) {
this.editingCommentId = comment.id;
this.editingCommentData = comment;
this.existingPhotosToDelete = [];
this.capturedPhotos = [];
// Update current location from comment
this.currentLocation = {
latitude: comment.latitude,
longitude: comment.longitude
};
// Get current status or default to memo
const currentStatus = comment.status || 'memo';
// Generate existing photos HTML
let existingPhotosHTML = '';
if (comment.photos && comment.photos.length > 0) {
existingPhotosHTML = '<div class="row g-2 mb-3" id="existing-photos-container">';
comment.photos.forEach(photo => {
existingPhotosHTML += `
<div class="col-6 col-md-4" data-photo-id="${photo.id}">
<div class="card position-relative">
<img src="${photo.url}" class="card-img-top" style="height: 120px; object-fit: cover;">
<button type="button" class="btn btn-sm btn-danger position-absolute top-0 end-0 m-1 delete-existing-photo" data-photo-id="${photo.id}">
<i class="bi bi-trash"></i>
</button>
${photo.heading !== null ? `
<span class="badge bg-primary position-absolute bottom-0 start-0 m-1">
<i class="bi bi-compass"></i> ${Math.round(photo.heading)}°
</span>
` : ''}
</div>
</div>
`;
});
existingPhotosHTML += '</div>';
}
const content = `
<div class="mb-3">
<strong>Statut:</strong>
<div class="d-flex flex-wrap gap-2 mt-2">
<button type="button" class="btn btn-sm ${currentStatus === 'memo' ? 'btn-primary' : 'btn-outline-primary'} status-badge${currentStatus === 'memo' ? ' active' : ''}" data-status="memo">
<i class="bi bi-sticky"></i> Mémo
</button>
<button type="button" class="btn btn-sm ${currentStatus === 'attention' ? 'btn-warning' : 'btn-outline-warning'} status-badge${currentStatus === 'attention' ? ' active' : ''}" data-status="attention">
<i class="bi bi-exclamation-triangle"></i> Attention
</button>
<button type="button" class="btn btn-sm ${currentStatus === 'todo' ? 'btn-info' : 'btn-outline-info'} status-badge${currentStatus === 'todo' ? ' active' : ''}" data-status="todo">
<i class="bi bi-list-check"></i> À faire
</button>
</div>
<div class="d-flex flex-wrap gap-2 mt-2">
<button type="button" class="btn btn-sm ${currentStatus === 'ok' ? 'btn-success' : 'btn-outline-success'} status-badge${currentStatus === 'ok' ? ' active' : ''}" data-status="ok">
<i class="bi bi-check-circle"></i> OK
</button>
<button type="button" class="btn btn-sm ${currentStatus === 'problem' ? 'btn-danger' : 'btn-outline-danger'} status-badge${currentStatus === 'problem' ? ' active' : ''}" data-status="problem">
<i class="bi bi-x-circle"></i> Problème
</button>
</div>
<input type="hidden" id="comment-status" value="${currentStatus}">
</div>
<div class="mb-3">
<label for="comment-text" class="form-label">Commentaire <span class="text-muted">(optionnel)</span></label>
<textarea id="comment-text" class="form-control" rows="3">${comment.comment || ''}</textarea>
</div>
<div class="mb-3">
<strong>Localisation:</strong>
<p id="comment-location-text" class="text-muted small mb-2">${comment.latitude.toFixed(6)}, ${comment.longitude.toFixed(6)}</p>
<button type="button" id="reposition-comment-btn" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-geo-alt"></i> Repositionner
</button>
</div>
<div class="mb-3">
<strong>Photos existantes:</strong>
${existingPhotosHTML || '<p class="text-muted">Aucune photo</p>'}
</div>
<div class="mb-3">
<strong>Nouvelles photos:</strong>
<div class="photo-capture-buttons d-flex gap-2 mt-2">
<button type="button" id="open-camera-btn" class="btn btn-sm btn-primary">
<i class="bi bi-camera"></i> Appareil photo
</button>
<button type="button" id="select-files-btn" class="btn btn-sm btn-secondary">
<i class="bi bi-folder2-open"></i> Sélectionner fichiers
</button>
<input type="file" id="photo-file-input" accept="image/*" multiple style="display: none;">
</div>
<div id="photos-preview" class="row g-2 mt-3"></div>
</div>
<div class="d-flex gap-2">
<button type="button" id="save-comment-btn" class="btn btn-success flex-grow-1">
Enregistrer les modifications
</button>
<button type="button" id="cancel-comment-btn" class="btn btn-secondary">
Annuler
</button>
</div>
`;
window.infoPanel.show('Modifier le commentaire', content);
// Setup event listeners
this.setupFormEventListeners();
// Setup delete existing photo buttons
document.querySelectorAll('.delete-existing-photo').forEach(btn => {
btn.addEventListener('click', (e) => {
const photoId = parseInt(e.currentTarget.dataset.photoId);
this.existingPhotosToDelete.push(photoId);
// Remove from DOM
const photoCard = e.currentTarget.closest('[data-photo-id]');
if (photoCard) {
photoCard.remove();
}
});
});
}
/**
* Create comment detail modal
*/
createCommentDetailModal() {
const modalHTML = `
<div class="modal fade" id="commentDetailModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-lg modal-dialog-scrollable">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="bi bi-chat-dots"></i> Détail du commentaire
</h5>
<div class="ms-auto d-flex gap-2 align-items-center">
<button type="button" id="edit-comment-btn" class="btn btn-sm btn-outline-primary" style="display: none;" title="Éditer le commentaire">
<i class="bi bi-pencil-fill"></i>
</button>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Fermer"></button>
</div>
</div>
<div class="modal-body">
<div class="mb-3">
<strong>Commentaire:</strong>
<p id="detail-comment-text"></p>
</div>
<div class="row mb-3">
<div class="col-6">
<strong>Auteur:</strong>
<p id="detail-comment-author"></p>
</div>
<div class="col-6">
<strong>Date:</strong>
<p id="detail-comment-date"></p>
</div>
</div>
<div class="mb-3">
<strong>Adresse:</strong>
<p id="detail-comment-address"></p>
</div>
<div class="mb-3">
<strong>Photos:</strong>
<div id="detail-comment-photos" class="row g-2 mt-2"></div>
</div>
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
}
/**
* Open photo in fullscreen modal
*/
openPhotoModal(photoUrl) {
let photoModal = document.getElementById('photoViewModal');
if (!photoModal) {
const modalHTML = `
<div class="modal fade" id="photoViewModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-fullscreen">
<div class="modal-content bg-dark">
<div class="modal-header border-0">
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body d-flex align-items-center justify-content-center p-0">
<img id="photo-fullscreen-img" src="" class="img-fluid" alt="Photo" style="max-height: 90vh;">
</div>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', modalHTML);
photoModal = document.getElementById('photoViewModal');
}
document.getElementById('photo-fullscreen-img').src = photoUrl;
const modal = new bootstrap.Modal(photoModal);
modal.show();
}
/**
* Open modal to add photos to existing comment
*/
openAddPhotosModal(commentId) {
// Store comment ID for later
this.editingCommentId = commentId;
// Reset captured photos
this.capturedPhotos = [];
// Change modal title
document.getElementById('modal-title-text').textContent = 'Ajouter des photos';
// Hide text field and quick badges
document.getElementById('comment-text').closest('.mb-3').style.display = 'none';
document.querySelector('.quick-text-badge')?.closest('.mb-3')?.setAttribute('style', 'display: none;');
// Clear preview
document.getElementById('comment-photos-preview').innerHTML = '';
// Change save button text
document.getElementById('save-comment-btn').innerHTML = '<i class="bi bi-plus-circle me-1"></i>Ajouter les photos';
// Close detail modal and open comment modal
const detailModal = bootstrap.Modal.getInstance(document.getElementById('commentDetailModal'));
if (detailModal) {
detailModal.hide();
}
this.commentModal.show();
// Reset on close (but not if repositioning)
document.getElementById('commentModal').addEventListener('hidden.bs.modal', () => {
if (this.editingCommentId && !this.isRepositioning) {
this.resetCommentModal();
this.editingCommentId = null;
}
}, { once: true });
}
/**
* Open modal to edit existing comment
*/
async openEditCommentModal(comment) {
// Store comment data for editing
this.editingCommentId = comment.id;
this.editingCommentData = comment;
// Set location for this comment
this.currentLocation = {
latitude: comment.latitude,
longitude: comment.longitude
};
// Change modal title
document.getElementById('modal-title-text').textContent = 'Éditer le commentaire';
// Fill comment text
document.getElementById('comment-text').value = comment.comment;
// Show text field and quick badges
document.getElementById('comment-text').closest('.mb-3').style.display = '';
document.querySelector('.quick-text-badge')?.closest('.mb-3')?.removeAttribute('style');
// Update location text
const locationText = document.getElementById('comment-location-text');
if (locationText) {
locationText.textContent = `${comment.latitude.toFixed(6)}, ${comment.longitude.toFixed(6)}`;
}
// Load existing photos with delete buttons
const previewContainer = document.getElementById('comment-photos-preview');
previewContainer.innerHTML = '';
// Reset captured photos (new photos to add)
this.capturedPhotos = [];
this.existingPhotosToDelete = [];
if (comment.photos && comment.photos.length > 0) {
comment.photos.forEach(photo => {
const col = document.createElement('div');
col.className = 'col-4';
col.innerHTML = `
<div class="position-relative">
<img src="${photo.url}" class="img-fluid rounded" alt="Photo" style="object-fit: cover; height: 100px;">
<button type="button" class="btn btn-sm btn-danger position-absolute top-0 end-0 m-1"
data-photo-id="${photo.id}">
<i class="bi bi-trash"></i>
</button>
${photo.heading !== null ? `
<span class="badge bg-dark position-absolute" style="bottom: 8px; left: 4px; font-size: 0.7rem;">
<i class="bi bi-compass"></i> ${Math.round(photo.heading)}°
</span>
` : ''}
</div>
`;
// Delete button handler
col.querySelector('button').addEventListener('click', (e) => {
const photoId = parseInt(e.currentTarget.dataset.photoId);
this.existingPhotosToDelete.push(photoId);
col.remove();
});
previewContainer.appendChild(col);
});
}
// Change save button text
document.getElementById('save-comment-btn').innerHTML = '<i class="bi bi-save me-1"></i>Enregistrer les modifications';
// Close detail modal and open comment modal
const detailModal = bootstrap.Modal.getInstance(document.getElementById('commentDetailModal'));
if (detailModal) {
detailModal.hide();
}
this.commentModal.show();
// Reset on close (but not if repositioning)
document.getElementById('commentModal').addEventListener('hidden.bs.modal', () => {
if (this.editingCommentId && !this.isRepositioning) {
this.resetCommentModal();
this.editingCommentId = null;
this.editingCommentData = null;
this.existingPhotosToDelete = [];
}
}, { once: true });
}
/**
* Reset comment modal to default state
*/
resetCommentModal() {
document.getElementById('modal-title-text').textContent = 'Nouveau commentaire';
document.getElementById('comment-text').closest('.mb-3').style.display = '';
document.querySelector('.quick-text-badge')?.closest('.mb-3')?.removeAttribute('style');
document.getElementById('save-comment-btn').innerHTML = '<i class="bi bi-save me-1"></i>Enregistrer';
this.capturedPhotos = [];
}
/**
* Start reposition mode - temporarily hide modal and allow point placement
*/
startRepositionMode() {
// Save original location in case of cancel
this.originalLocation = {...this.currentLocation};
// Set flag to indicate repositioning
this.isRepositioning = true;
// No need to hide panel - it stays visible on the side!
// Just update the panel to show repositioning state
const panelContent = document.getElementById('info-panel-content');
if (panelContent) {
// Disable form inputs during repositioning
const inputs = panelContent.querySelectorAll('input, textarea, button:not(#finish-reposition-btn):not(#cancel-reposition-btn)');
inputs.forEach(input => input.disabled = true);
// Add repositioning instructions at the top
const instructions = document.createElement('div');
instructions.id = 'reposition-instructions';
instructions.className = 'alert alert-info mb-3';
instructions.innerHTML = `
<strong><i class="bi bi-info-circle"></i> Mode repositionnement</strong>
<p class="mb-2 small">Déplacez le marqueur sur la carte ou cliquez pour le repositionner.</p>
<div class="d-flex gap-2">
<button type="button" id="finish-reposition-btn" class="btn btn-sm btn-success">
<i class="bi bi-check"></i> Terminer
</button>
<button type="button" id="cancel-reposition-btn" class="btn btn-sm btn-secondary">
<i class="bi bi-x"></i> Annuler
</button>
</div>
`;
panelContent.insertBefore(instructions, panelContent.firstChild);
// Setup button handlers
document.getElementById('finish-reposition-btn').addEventListener('click', () => {
this.finishRepositionMode();
});
document.getElementById('cancel-reposition-btn').addEventListener('click', () => {
this.cancelRepositionMode();
});
}
// Create temporary marker at current location
this.createRepositionMarker();
// Set up map click handler
this.repositionClickHandler = (e) => {
const { lng, lat } = e.lngLat;
this.updateRepositionMarker(lng, lat);
};
this.map.on('click', this.repositionClickHandler);
}
/**
* Create draggable marker for repositioning
*/
createRepositionMarker() {
if (!this.currentLocation) {
return;
}
// Remove existing marker if any
if (this.repositionMarker) {
this.repositionMarker.remove();
this.repositionMarker = null;
}
// Create a simple marker element WITHOUT any CSS class
const el = document.createElement('div');
// Pas de className pour éviter tout conflit CSS
// Simple pin icon
el.innerHTML = `
<svg width="30" height="40" viewBox="0 0 30 40" xmlns="http://www.w3.org/2000/svg">
<path d="M15 0C6.716 0 0 6.716 0 15c0 8.284 15 25 15 25s15-16.716 15-25C30 6.716 23.284 0 15 0z"
fill="#0d6efd" stroke="white" stroke-width="2"/>
<circle cx="15" cy="15" r="5" fill="white"/>
</svg>
`;
// Add pulse animation if not already defined
if (!document.getElementById('marker-pulse-animation')) {
const style = document.createElement('style');
style.id = 'marker-pulse-animation';
style.textContent = `
.maplibregl-marker svg {
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.3));
}
`;
document.head.appendChild(style);
}
// Create marker at current location (draggable pour les utilisateurs qui préfèrent)
this.repositionMarker = new maplibregl.Marker({
element: el,
draggable: true,
anchor: 'bottom'
})
.setLngLat([this.currentLocation.longitude, this.currentLocation.latitude])
.addTo(this.map);
// Update location on drag
this.repositionMarker.on('dragend', () => {
const lngLat = this.repositionMarker.getLngLat();
this.currentLocation = {
latitude: lngLat.lat,
longitude: lngLat.lng
};
// Mark location as manually set to prevent GPS updates
this.locationManuallySet = true;
// Update location text in form
const locationText = document.getElementById('comment-location-text');
if (locationText) {
locationText.textContent = `${lngLat.lat.toFixed(6)}, ${lngLat.lng.toFixed(6)}`;
}
});
}
/**
* Update marker position
*/
updateRepositionMarker(lng, lat) {
// Update current location first
this.currentLocation = {
latitude: lat,
longitude: lng
};
// Mark location as manually set to prevent GPS updates
this.locationManuallySet = true;
// Create marker if it doesn't exist yet
if (!this.repositionMarker) {
this.createRepositionMarker();
} else {
// Update existing marker position
this.repositionMarker.setLngLat([lng, lat]);
}
// Update location text in form
const locationText = document.getElementById('comment-location-text');
if (locationText) {
locationText.textContent = `${lat.toFixed(6)}, ${lng.toFixed(6)}`;
}
}
/**
* Show overlay with finish button
*/
showRepositionOverlay() {
let overlay = document.getElementById('reposition-overlay');
if (!overlay) {
const overlayHTML = `
<div id="reposition-overlay" style="position: fixed; top: 0; left: 0; right: 0; z-index: 9999;
background: rgba(13, 110, 253, 0.95); padding: 1rem; text-align: center; color: white;
box-shadow: 0 2px 8px rgba(0,0,0,0.2);">
<div class="container">
<div class="d-flex align-items-center justify-content-between">
<div>
<i class="bi bi-cursor-fill me-2"></i>
<strong>Cliquez sur la carte ou déplacez le marqueur pour repositionner</strong>
</div>
<button type="button" id="finish-reposition-btn" class="btn btn-light">
<i class="bi bi-check-lg me-1"></i>Terminer
</button>
</div>
</div>
</div>
`;
document.body.insertAdjacentHTML('beforeend', overlayHTML);
overlay = document.getElementById('reposition-overlay');
// Finish button handler
document.getElementById('finish-reposition-btn').addEventListener('click', () => {
this.finishRepositionMode();
});
}
overlay.style.display = 'block';
}
/**
* Finish reposition mode and return to form
*/
finishRepositionMode() {
// Remove map click handler
if (this.repositionClickHandler) {
this.map.off('click', this.repositionClickHandler);
this.repositionClickHandler = null;
}
// Remove marker
if (this.repositionMarker) {
this.repositionMarker.remove();
this.repositionMarker = null;
}
// Clear repositioning flag
this.isRepositioning = false;
// Remove instructions and re-enable inputs
const instructions = document.getElementById('reposition-instructions');
if (instructions) {
instructions.remove();
}
const panelContent = document.getElementById('info-panel-content');
if (panelContent) {
const inputs = panelContent.querySelectorAll('input, textarea, button');
inputs.forEach(input => input.disabled = false);
}
// Update location text
const locationText = document.getElementById('comment-location-text');
if (locationText && this.currentLocation) {
locationText.textContent = `${this.currentLocation.latitude.toFixed(6)}, ${this.currentLocation.longitude.toFixed(6)}`;
}
}
/**
* Cancel reposition mode
*/
cancelRepositionMode() {
// Restore original location
if (this.originalLocation) {
this.currentLocation = {...this.originalLocation};
}
// Clean up marker using dedicated method
this.cleanupRepositionMarker();
this.finishRepositionMode();
}
/**
* Diagnostic method for debugging
*/
diagnose() {
// Diagnostic method removed - use browser dev tools for debugging
}
/**
* Force re-initialization of map layers (for debugging)
*/
forceInit() {
this.mapLayersInitialized = false;
this.setupMapSource();
}
}