diff --git a/.github/avancement.md b/.github/avancement.md
index 7aabd171..2008c15f 100644
--- a/.github/avancement.md
+++ b/.github/avancement.md
@@ -244,7 +244,7 @@ Score global : **9/10** — 4/5 axes de remédiation implémentés.
4. **Rendre les interactions asynchrones (fetch/AJAX) au lieu de POST + reload complet** — ✅ fait pour create/edit/delete de la gestion des invités (`guest_management_controller.js`) ; reste à généraliser le même pattern (détection `Accept: application/json` + fallback progressif + contrôleur Stimulus) aux autres actions POST+redirect encore existantes (albums, dossiers, fichiers, partages...)
5. **Passer l'envoi des emails de notification en asynchrone (Messenger)** — `ShareNotificationMailer::notify()` envoie actuellement de façon synchrone pendant la requête `/share-create`, ce qui bloque la réponse le temps de l'envoi ; faire passer par un message Messenger + worker/transport dédié
6. **Nettoyer les ~168 notices PHPUnit** — `createMock()` utilisé sans `expects()` déclenche une notice PHPUnit 13 ("Consider refactoring your test code to use a test stub instead") ; concentrées dans `tests/Unit/{Entity,EventListener,Security,Service}`, notamment `ShareLinkFactoryTest.php` (~15) ; remplacer ces `createMock()` par `createStub()` là où aucune expectation n'est réellement vérifiée (le chantier worker média a déjà converti quelques cas)
-7. **EXIF des fichiers RAW** — `ExifService` s'appuie sur `exif_read_data()`, qui ne lit pas les conteneurs RAW : date de prise de vue, modèle d'appareil et GPS restent vides pour ces photos (la vignette, elle, fonctionne depuis l'intégration de `ronanlenouvel/raw-preview-extractor`). Deux pistes : lire les EXIF de la preview JPEG extraite (simple, mais métadonnées appauvries), ou exposer les tags TIFF depuis le package (plus riche — réglages complets, résolution capteur réelle)
+7. ✅ **FAIT — Pack photographe EXIF (RAW + JPEG)** (#268) : `ronanlenouvel/raw-preview-extractor` **1.2.0** expose `ExtractedPreview->metadata` (date, ouverture, vitesse, ISO, focale, objectif, appareil), lu depuis l'EXIF du RAW ; `ExifService` fait de même pour les JPEG. `Media` gagne 5 colonnes (aperture, shutter_speed, iso, focal_length, lens), exposées par l'API, affichées dans un panneau EXIF du lightbox galerie, avec tri « date de prise de vue ». CR3 : métadonnées `null` pour l'instant (parseur ISO-BMFF, piste d'itération)
8. **Optimiser le redimensionnement des grandes previews** — une preview RAW pleine résolution (8256×5504) prend ~2,9 s à redimensionner sous GD, contre 23 ms pour l'extraction elle-même. Acceptable car le pipeline média est asynchrone (Messenger), mais un redimensionnement en deux passes réduirait nettement le coût
9. **Chiffrement au repos** — `APP_ENCRYPTION_KEY` est déclaré dans `config/services.yaml` mais injecté nulle part : les fichiers sont stockés en clair. Prévu comme option laissée à l'utilisateur. Incohérence résiduelle : le docblock `@param` de `ThumbnailService::generate()` (ligne 43) dit encore « chiffrée sur disque » alors que le reste de la classe (ligne 24) indique correctement « stockés en clair » — à corriger
10. ✅ **RÉSOLU** — ~~Extensions PHP sous-déclarées~~ : `composer.json` requiert désormais `ext-ctype`, `ext-iconv`, **`ext-gd`** et **`ext-exif`**. Plus de risque d'échec silencieux à l'exécution
diff --git a/assets/controllers/gallery_lightbox_controller.js b/assets/controllers/gallery_lightbox_controller.js
index c5ec3d80..44c356af 100644
--- a/assets/controllers/gallery_lightbox_controller.js
+++ b/assets/controllers/gallery_lightbox_controller.js
@@ -1,11 +1,12 @@
import { Controller } from '@hotwired/stimulus';
import Slideshow from './Slideshow.js';
+import { buildExifPanelHtml } from '../js/exif-panel.js';
/* Lightbox de la galerie médias : ouvre le média en plein écran au clic
* sur une vignette, navigation précédent/suivant au clavier et via boutons.
* Le diaporama (auto-avance, pause/lecture) est délégué à Slideshow. */
export default class extends Controller {
- static targets = ['img', 'video', 'prev', 'next', 'play'];
+ static targets = ['img', 'video', 'prev', 'next', 'play', 'info'];
connect() {
this.links = Array.from(document.querySelectorAll('[data-lightbox]'));
@@ -65,11 +66,33 @@ export default class extends Controller {
this.videoTarget.style.display = 'none';
}
+ this.renderInfo(isVideo ? null : this.links[this.current]);
+
this.element.style.display = 'flex';
this.prevTarget.style.visibility = this.current > 0 ? 'visible' : 'hidden';
this.nextTarget.style.visibility = this.current < this.srcs.length - 1 ? 'visible' : 'hidden';
}
+ /* Remplit (ou masque) le panneau EXIF depuis les data-* de la vignette. */
+ renderInfo(link) {
+ if (!this.hasInfoTarget) return;
+
+ const html = link ? buildExifPanelHtml({
+ takenAt: link.getAttribute('data-taken-at') || '',
+ camera: link.getAttribute('data-camera') || '',
+ aperture: link.getAttribute('data-aperture') || '',
+ shutter: link.getAttribute('data-shutter') || '',
+ iso: link.getAttribute('data-iso') || '',
+ focal: link.getAttribute('data-focal') || '',
+ lens: link.getAttribute('data-lens') || '',
+ gpsLat: link.getAttribute('data-gps-lat') || '',
+ gpsLon: link.getAttribute('data-gps-lon') || '',
+ }) : '';
+
+ this.infoTarget.innerHTML = html;
+ this.infoTarget.hidden = html === '';
+ }
+
prev() {
this.show(this.current - 1);
}
diff --git a/assets/js/exif-panel.js b/assets/js/exif-panel.js
new file mode 100644
index 00000000..896fa45f
--- /dev/null
+++ b/assets/js/exif-panel.js
@@ -0,0 +1,53 @@
+/**
+ * Panneau EXIF du lightbox (#268) — construction pure et testable du contenu à
+ * partir des attributs data-* d'une vignette photo. Aucune dépendance au DOM du
+ * contrôleur : le rendu se teste isolément.
+ */
+
+function escapeHtml(text) {
+ const map = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
+ return String(text).replace(/[&<>"']/g, (m) => map[m]);
+}
+
+/**
+ * Réduit un dataset de vignette aux réglages présents (champs vides ignorés).
+ *
+ * @param {Object} d - { takenAt, camera, aperture, shutter, iso, focal, lens }
+ * @returns {Array<{label: string, value: string}>}
+ */
+export function buildExifItems(d = {}) {
+ const items = [];
+ if (d.takenAt) items.push({ label: 'Prise le', value: d.takenAt });
+ if (d.camera) items.push({ label: 'Appareil', value: d.camera });
+ if (d.aperture) items.push({ label: 'Ouverture', value: `f/${d.aperture}` });
+ if (d.shutter) items.push({ label: 'Vitesse', value: `${d.shutter}s` });
+ if (d.iso) items.push({ label: 'ISO', value: String(d.iso) });
+ if (d.focal) items.push({ label: 'Focale', value: `${d.focal} mm` });
+ if (d.lens) items.push({ label: 'Objectif', value: d.lens });
+ return items;
+}
+
+/**
+ * Rend le panneau EXIF en HTML, ou une chaîne vide s'il n'y a rien à montrer.
+ *
+ * @param {Object} d - dataset de la vignette (data-* déjà lus)
+ * @returns {string}
+ */
+export function buildExifPanelHtml(d = {}) {
+ const items = buildExifItems(d);
+ let html = items
+ .map(
+ (i) =>
+ `${escapeHtml(i.label)} ${escapeHtml(i.value)}`,
+ )
+ .join('');
+
+ if (d.gpsLat && d.gpsLon) {
+ const lat = encodeURIComponent(d.gpsLat);
+ const lon = encodeURIComponent(d.gpsLon);
+ const url = `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=15/${lat}/${lon}`;
+ html += `📍 Voir sur la carte`;
+ }
+
+ return html;
+}
diff --git a/assets/styles/gallery.css b/assets/styles/gallery.css
index 9d296fc3..19b8c05f 100644
--- a/assets/styles/gallery.css
+++ b/assets/styles/gallery.css
@@ -407,3 +407,33 @@
right: 0.5rem;
}
}
+
+/* Panneau EXIF du lightbox (réglages de prise de vue) — #268 */
+.hc-lightbox-exif {
+ position: absolute;
+ left: 50%;
+ bottom: 1.25rem;
+ transform: translateX(-50%);
+ max-width: min(92vw, 900px);
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 0.4rem 0.9rem;
+ padding: 0.55rem 0.9rem;
+ border-radius: 0.75rem;
+ background: rgba(0, 0, 0, 0.55);
+ backdrop-filter: blur(8px);
+ color: #fff;
+ font-size: 0.82rem;
+ line-height: 1.3;
+ pointer-events: auto;
+}
+.hc-lightbox-exif[hidden] { display: none; }
+.hc-exif-item { white-space: nowrap; }
+.hc-exif-label { opacity: 0.6; margin-right: 0.15rem; }
+.hc-exif-gps { color: #fff; text-decoration: none; }
+.hc-exif-gps:hover { text-decoration: underline; }
+
+@media (max-width: 767px) {
+ .hc-lightbox-exif { font-size: 0.75rem; bottom: 0.6rem; gap: 0.3rem 0.6rem; }
+}
diff --git a/assets/tests/exif-panel.test.js b/assets/tests/exif-panel.test.js
new file mode 100644
index 00000000..8bddb683
--- /dev/null
+++ b/assets/tests/exif-panel.test.js
@@ -0,0 +1,61 @@
+import { describe, test, expect } from '@jest/globals';
+
+const { buildExifItems, buildExifPanelHtml } = await import('../js/exif-panel.js');
+
+describe('buildExifItems', () => {
+ test('ne garde que les réglages présents', () => {
+ const items = buildExifItems({
+ takenAt: '15/06/2024 12:30',
+ camera: 'NIKON Z 6',
+ aperture: '2.8',
+ shutter: '1/250',
+ iso: '400',
+ focal: '50',
+ lens: 'NIKKOR Z 50mm',
+ });
+ expect(items).toEqual([
+ { label: 'Prise le', value: '15/06/2024 12:30' },
+ { label: 'Appareil', value: 'NIKON Z 6' },
+ { label: 'Ouverture', value: 'f/2.8' },
+ { label: 'Vitesse', value: '1/250s' },
+ { label: 'ISO', value: '400' },
+ { label: 'Focale', value: '50 mm' },
+ { label: 'Objectif', value: 'NIKKOR Z 50mm' },
+ ]);
+ });
+
+ test('champs vides ignorés', () => {
+ expect(buildExifItems({ aperture: '', iso: '400' })).toEqual([
+ { label: 'ISO', value: '400' },
+ ]);
+ });
+
+ test('dataset vide → aucun item', () => {
+ expect(buildExifItems({})).toEqual([]);
+ });
+});
+
+describe('buildExifPanelHtml', () => {
+ test('rend les réglages en HTML', () => {
+ const html = buildExifPanelHtml({ aperture: '2.8', iso: '400' });
+ expect(html).toContain('f/2.8');
+ expect(html).toContain('ISO');
+ expect(html).toContain('400');
+ });
+
+ test('ajoute un lien carte quand le GPS est présent', () => {
+ const html = buildExifPanelHtml({ gpsLat: '48.8566', gpsLon: '2.3522' });
+ expect(html).toContain('openstreetmap.org');
+ expect(html).toContain('48.8566');
+ });
+
+ test('chaîne vide quand rien à montrer', () => {
+ expect(buildExifPanelHtml({})).toBe('');
+ });
+
+ test('échappe le HTML des valeurs (anti-XSS)', () => {
+ const html = buildExifPanelHtml({ lens: '' });
+ expect(html).not.toContain('