2435 lines
98 KiB
JavaScript
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();
|
|
}
|
|
}
|