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('