feat: add photo carousel for controls and improve mobile touch gestures for remark images

This commit is contained in:
kdeterme 2026-08-17 10:49:01 +02:00
parent 4e35da62b8
commit adb5f08628
13 changed files with 201 additions and 15 deletions

View file

@ -1097,3 +1097,9 @@ msgstr "PDF generated and saved successfully."
#: streetup/controls/views_reports_pdf.py:845
msgid "Erreur lors de la génération du PDF"
msgstr "Error generating the PDF"
msgid "Photos du contrôle"
msgstr "Control photos"
msgid "Parcourir toutes les photos du contrôle dans le carrousel"
msgstr "Browse all control photos in the carousel"

View file

@ -144,3 +144,10 @@ msgstr ""
#: controls/static/controls/remarks_form.js:222
msgid "Aucune caméra disponible sur cet appareil."
msgstr "No camera available on this device."
msgid "Photos du contrôle"
msgstr "Control photos"
msgid "Photos de la remarque"
msgstr "Remark photos"

View file

@ -1092,3 +1092,10 @@ msgstr "PDF succesvol gegenereerd en opgeslagen."
#: controls/views_reports_pdf.py:845
msgid "Erreur lors de la génération du PDF"
msgstr "Fout bij het genereren van de PDF"
msgid "Photos du contrôle"
msgstr "Controlefoto's"
msgid "Parcourir toutes les photos du contrôle dans le carrousel"
msgstr "Bekijk alle controlefoto's in de carrousel"

View file

@ -144,3 +144,10 @@ msgstr ""
#: controls/static/controls/remarks_form.js:222
msgid "Aucune caméra disponible sur cet appareil."
msgstr "Er is geen camera beschikbaar op dit apparaat."
msgid "Photos du contrôle"
msgstr "Controlefoto's"
msgid "Photos de la remarque"
msgstr "Opmerking foto's"

View file

@ -49,10 +49,53 @@ body {
background-color: #000;
}
#remarkPhotoCarousel {
touch-action: pan-y;
user-select: none;
-webkit-user-select: none;
}
#remarkPhotoCarousel .carousel-control-prev,
#remarkPhotoCarousel .carousel-control-next {
width: 15%;
opacity: 0.85;
}
#remarkPhotoCarousel .carousel-control-prev-icon,
#remarkPhotoCarousel .carousel-control-next-icon {
width: 2.5rem;
height: 2.5rem;
background-color: rgba(0, 0, 0, 0.65);
border-radius: 50%;
background-size: 60%;
}
#remarkPhotoCarousel .carousel-indicators [data-bs-target] {
width: 8px;
height: 8px;
border-radius: 50%;
margin: 0 4px;
background-color: rgba(255, 255, 255, 0.6);
}
#remarkPhotoCarousel .carousel-indicators .active {
background-color: #0d6efd;
transform: scale(1.3);
}
@media (max-width: 576px) {
#remarkPhotoCarouselModal .modal-content {
height: 100dvh;
border-radius: 0;
}
#remarkPhotoCarouselModal .modal-body {
flex: 1 1 auto;
min-height: 0;
max-height: none !important;
}
#remarkPhotoCarousel .carousel-inner img {
max-height: 70dvh !important;
}
}
#remark-form-container {

View file

@ -1795,15 +1795,54 @@ function bindAutocompleteForRow(card) {
}
})();
// Global variables for remark photo carousel
// Global variables for remark / control photo carousel
let currentRemarkCarouselPhotos = [];
let currentRemarkCarouselIndex = 0;
function setCarouselModalTitle(titleText) {
const titleEl = document.getElementById('remarkPhotoCarouselModalLabel');
if (titleEl) {
titleEl.textContent = titleText;
}
}
// Ouvrir toutes les photos du contrôle dans le carrousel
function openAllControlPhotosCarousel(startIndex = 0) {
const thumbs = Array.from(document.querySelectorAll('#remarks-list .remark-photo-thumb, #archives-list .remark-photo-thumb'));
if (thumbs.length === 0) {
const fallbackThumbs = Array.from(document.querySelectorAll('.remark-photo-thumb'));
if (fallbackThumbs.length === 0) return;
thumbs.push(...fallbackThumbs);
}
currentRemarkCarouselPhotos = thumbs.map(t => ({
fullUrl: t.dataset.fullUrl || t.src,
thumbUrl: t.dataset.thumbUrl || t.src,
imageId: t.dataset.imageId || null,
caption: t.dataset.caption || '',
remarkId: t.dataset.remarkId || null,
element: t
}));
let targetIndex = 0;
if (typeof startIndex === 'number') {
targetIndex = Math.max(0, Math.min(startIndex, currentRemarkCarouselPhotos.length - 1));
} else if (startIndex instanceof HTMLElement) {
targetIndex = thumbs.indexOf(startIndex);
if (targetIndex < 0) targetIndex = 0;
}
const controlTitle = typeof gettext !== 'undefined' ? gettext('Photos du contrôle') : 'Photos du contrôle';
setCarouselModalTitle(controlTitle);
showRemarkCarouselModal(targetIndex);
}
// Ouvrir les photos d'une remarque spécifique dans le carrousel
function openRemarkPhotoCarousel(thumbElement) {
if (!thumbElement) return;
const remarkId = thumbElement.dataset.remarkId;
// Find all photo thumbnails belonging to this remark
// Trouver toutes les photos appartenant à cette remarque
let thumbs = [];
const remarkCard = document.getElementById(`remark-${remarkId}`);
if (remarkCard) {
@ -1828,6 +1867,8 @@ function openRemarkPhotoCarousel(thumbElement) {
let startIndex = thumbs.indexOf(thumbElement);
if (startIndex < 0) startIndex = 0;
const remarkTitle = typeof gettext !== 'undefined' ? gettext('Photos de la remarque') : 'Photos de la remarque';
setCarouselModalTitle(remarkTitle);
showRemarkCarouselModal(startIndex);
}
@ -1844,6 +1885,51 @@ function openRemarkPhotoCarouselByRemarkId(remarkId) {
}
}
// Gestion des gestes tactiles (swipe mobile)
function setupCarouselTouchGestures(carouselEl) {
if (!carouselEl || carouselEl.dataset.touchAttached === 'true') return;
carouselEl.dataset.touchAttached = 'true';
let touchStartX = 0;
let touchStartY = 0;
let touchEndX = 0;
let touchEndY = 0;
carouselEl.addEventListener('touchstart', function(e) {
if (e.touches.length === 1) {
touchStartX = e.touches[0].clientX;
touchStartY = e.touches[0].clientY;
touchEndX = touchStartX;
touchEndY = touchStartY;
}
}, { passive: true });
carouselEl.addEventListener('touchmove', function(e) {
if (e.touches.length === 1) {
touchEndX = e.touches[0].clientX;
touchEndY = e.touches[0].clientY;
}
}, { passive: true });
carouselEl.addEventListener('touchend', function(e) {
const diffX = touchEndX - touchStartX;
const diffY = touchEndY - touchStartY;
// Détecter un balayage horizontal net (distance > 35px et plus horizontal que vertical)
if (Math.abs(diffX) > 35 && Math.abs(diffX) > Math.abs(diffY) * 1.2) {
if (typeof bootstrap !== 'undefined' && bootstrap.Carousel) {
const bsCarousel = bootstrap.Carousel.getInstance(carouselEl);
if (bsCarousel) {
if (diffX < 0) {
bsCarousel.next(); // Balayage vers la gauche -> Photo suivante
} else {
bsCarousel.prev(); // Balayage vers la droite -> Photo précédente
}
}
}
}
}, { passive: true });
}
function showRemarkCarouselModal(startIndex) {
const modalEl = document.getElementById('remarkPhotoCarouselModal');
if (!modalEl) {
@ -1868,7 +1954,7 @@ function showRemarkCarouselModal(startIndex) {
item.className = `carousel-item ${idx === startIndex ? 'active' : ''} h-100`;
item.innerHTML = `
<div class="d-flex align-items-center justify-content-center h-100 p-2">
<img src="${photo.fullUrl}" class="d-block img-fluid mh-100 mw-100" style="object-fit: contain; max-height: 75vh;" alt="${photo.caption}">
<img src="${photo.fullUrl}" class="d-block img-fluid mh-100 mw-100 rounded" style="object-fit: contain; max-height: 75vh;" alt="${photo.caption}">
</div>
`;
carouselInner.appendChild(item);
@ -1901,9 +1987,11 @@ function showRemarkCarouselModal(startIndex) {
if (bsCarousel) {
bsCarousel.dispose();
}
bsCarousel = new bootstrap.Carousel(carouselEl, { ride: false, interval: false });
bsCarousel = new bootstrap.Carousel(carouselEl, { ride: false, interval: false, touch: true });
bsCarousel.to(startIndex);
setupCarouselTouchGestures(carouselEl);
carouselEl.addEventListener('slid.bs.carousel', function (e) {
updateRemarkCarouselActiveSlide(e.to);
}, { once: false });
@ -1975,6 +2063,11 @@ document.addEventListener('DOMContentLoaded', function() {
bsCarousel.next();
}
});
const carouselEl = document.getElementById('remarkPhotoCarousel');
if (carouselEl) {
setupCarouselTouchGestures(carouselEl);
}
}
});

View file

@ -91,6 +91,16 @@
</p>
<p class="mb-1"><strong>{% translate 'Lieu' %} :</strong> {{ control.location }}</p>
{% endif %}
{% if control_photos_count > 0 %}
<div class="mt-3">
<button type="button" class="btn btn-outline-primary d-inline-flex align-items-center gap-2 py-2 px-3 shadow-sm rounded-3" onclick="openAllControlPhotosCarousel(0)" title="{% translate 'Parcourir toutes les photos du contrôle dans le carrousel' %}">
<i class="bi bi-images fs-5"></i>
<span class="fw-semibold">{% translate "Photos du contrôle" %}</span>
<span class="badge bg-primary rounded-pill">{{ control_photos_count }}</span>
</button>
</div>
{% endif %}
</div>
<!-- </div> -->
@ -776,20 +786,20 @@
</div>
<!-- Modal Carrousel pour les photos d'une remarque -->
<!-- Modal Carrousel pour les photos d'un contrôle ou d'une remarque -->
<div class="modal fade" id="remarkPhotoCarouselModal" tabindex="-1" aria-labelledby="remarkPhotoCarouselModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-dialog modal-fullscreen-sm-down modal-xl modal-dialog-centered">
<div class="modal-content bg-dark text-white shadow-lg border-0">
<div class="modal-header border-bottom border-secondary py-2 px-3">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-images text-primary fs-5"></i>
<h5 class="modal-title fs-6 mb-0 text-white" id="remarkPhotoCarouselModalLabel">{% translate "Photos de la remarque" %}</h5>
<h5 class="modal-title fs-6 mb-0 text-white" id="remarkPhotoCarouselModalLabel">{% translate "Photos du contrôle" %}</h5>
<span class="badge bg-primary rounded-pill px-2 py-1 fs-7" id="remark-carousel-counter">1 / 1</span>
</div>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body p-0 position-relative bg-black d-flex align-items-center justify-content-center overflow-hidden" style="min-height: 350px; max-height: 80vh;">
<div id="remarkPhotoCarousel" class="carousel slide w-100 h-100 d-flex flex-column justify-content-center" data-bs-ride="false">
<div id="remarkPhotoCarousel" class="carousel slide w-100 h-100 d-flex flex-column justify-content-center" data-bs-ride="false" data-bs-touch="true">
<div class="carousel-indicators mb-2" id="remark-carousel-indicators"></div>
<div class="carousel-inner h-100" id="remark-carousel-inner"></div>
<button class="carousel-control-prev" type="button" data-bs-target="#remarkPhotoCarousel" data-bs-slide="prev" style="z-index: 10;">

View file

@ -127,6 +127,16 @@
{{ control.description }}
</p>
{% endif %}
{% if control_photos_count > 0 %}
<div class="mt-3">
<button type="button" class="btn btn-outline-primary w-100 d-flex align-items-center justify-content-center gap-2 py-2 shadow-sm rounded-3" onclick="openAllControlPhotosCarousel(0)" title="{% translate 'Parcourir toutes les photos du contrôle dans le carrousel' %}">
<i class="bi bi-images fs-5"></i>
<span class="fw-semibold">{% translate "Photos du contrôle" %}</span>
<span class="badge bg-primary rounded-pill">{{ control_photos_count }}</span>
</button>
</div>
{% endif %}
</div>
@ -1151,20 +1161,22 @@ document.getElementById('force-sync-btn')?.addEventListener('click', async () =>
await displayPendingRemarks();
}
});
<!-- Modal Carrousel pour les photos d'une remarque -->
</script>
<!-- Modal Carrousel pour les photos d'un contrôle ou d'une remarque -->
<div class="modal fade" id="remarkPhotoCarouselModal" tabindex="-1" aria-labelledby="remarkPhotoCarouselModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl modal-dialog-centered">
<div class="modal-dialog modal-fullscreen-sm-down modal-xl modal-dialog-centered">
<div class="modal-content bg-dark text-white shadow-lg border-0">
<div class="modal-header border-bottom border-secondary py-2 px-3">
<div class="d-flex align-items-center gap-2">
<i class="bi bi-images text-primary fs-5"></i>
<h5 class="modal-title fs-6 mb-0 text-white" id="remarkPhotoCarouselModalLabel">{% translate "Photos de la remarque" %}</h5>
<h5 class="modal-title fs-6 mb-0 text-white" id="remarkPhotoCarouselModalLabel">{% translate "Photos du contrôle" %}</h5>
<span class="badge bg-primary rounded-pill px-2 py-1 fs-7" id="remark-carousel-counter">1 / 1</span>
</div>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="{% translate 'Fermer' %}"></button>
</div>
<div class="modal-body p-0 position-relative bg-black d-flex align-items-center justify-content-center overflow-hidden" style="min-height: 300px; max-height: 80vh;">
<div id="remarkPhotoCarousel" class="carousel slide w-100 h-100 d-flex flex-column justify-content-center" data-bs-ride="false">
<div id="remarkPhotoCarousel" class="carousel slide w-100 h-100 d-flex flex-column justify-content-center" data-bs-ride="false" data-bs-touch="true">
<div class="carousel-indicators mb-2" id="remark-carousel-indicators"></div>
<div class="carousel-inner h-100" id="remark-carousel-inner"></div>
<button class="carousel-control-prev" type="button" data-bs-target="#remarkPhotoCarousel" data-bs-slide="prev" style="z-index: 10;">
@ -1194,7 +1206,6 @@ document.getElementById('force-sync-btn')?.addEventListener('click', async () =>
</div>
</div>
</div>
</script>
{% endblock content %}

View file

@ -729,8 +729,9 @@ def controls_detail(request, control_id):
remarks = control.remarks.exclude(archived=True)
archives = control.remarks.filter(archived=True)
remarks = control.remarks.exclude(archived=True).prefetch_related('images', 'history__images', 'remark_tags__category', 'quantities')
archives = control.remarks.filter(archived=True).prefetch_related('images', 'remark_tags__category')
control_photos_count = RemarkImage.objects.filter(remark__in=remarks).count()
plans = control.reference_plans.all()
reports = control.reports.all()
categories = Category.objects.all()
@ -783,6 +784,7 @@ def controls_detail(request, control_id):
"control": control,
"remarks": remarks,
"archives": archives,
"control_photos_count": control_photos_count,
"plans": plans,
"reports": reports,
"categories": categories,