Lou·Ka — tous les logements à louer du Québec, un seul endroit.
HTML 98.9%
Python 0.6%
1// -----------------------------------------------------------------------------2// Lou-Ka — Agrégateur de logements à louer (province de Québec)3// Auteur : Simon-Pierre Boucher — contact@spboucher.ai4// fiche/PropertyGallery.tsx : galerie premium — grande image, balayage natif5// (scroll-snap), compteur « 1 / 12 », bouton plein écran, flèches au survol6// (desktop), vignettes ≥ 768 px, préchargement de la photo suivante, lazy7// loading des autres. Lightbox : balayage + pincement pour zoomer + double8// tape (logique conservée de la fiche v2). URLs d'images inchangées9// (miniatures WebP via SmartImg → repli original → visuel de secours).10// -----------------------------------------------------------------------------11import { useEffect, useRef, useState } from "react";12import SmartImg from "../components/SmartImg";13import { IcoChevronLeft, IcoChevronRight, IcoClose, IcoExpand } from "../components/Icons";14import { thumb } from "../api";1516function Lightbox({ images, start, titre, onClose }:17 { images: string[]; start: number; titre: string; onClose: () => void }) {18 const [idx, setIdx] = useState(start);19 const [scale, setScale] = useState(1);20 const [tx, setTx] = useState(0);21 const [ty, setTy] = useState(0);22 const track = useRef<HTMLDivElement>(null);23 const pointers = useRef(new Map<number, { x: number; y: number }>());24 const pinch = useRef<{ d: number; scale: number } | null>(null);25 const lastTap = useRef(0);2627 useEffect(() => {28 track.current?.scrollTo({ left: start * track.current.clientWidth });29 document.documentElement.classList.add("ka-scroll-lock");30 const onKey = (e: KeyboardEvent) => {31 if (e.key === "Escape") onClose();32 if (e.key === "ArrowRight") go(1);33 if (e.key === "ArrowLeft") go(-1);34 };35 window.addEventListener("keydown", onKey);36 return () => {37 document.documentElement.classList.remove("ka-scroll-lock");38 window.removeEventListener("keydown", onKey);39 };40 // eslint-disable-next-line react-hooks/exhaustive-deps41 }, []);4243 const resetZoom = () => { setScale(1); setTx(0); setTy(0); };44 const go = (d: number) => {45 const el = track.current; if (!el) return;46 const i = Math.max(0, Math.min(images.length - 1, Math.round(el.scrollLeft / el.clientWidth) + d));47 el.scrollTo({ left: i * el.clientWidth, behavior: "smooth" });48 };49 const onScroll = () => {50 const el = track.current;51 if (el && scale === 1) {52 const i = Math.round(el.scrollLeft / el.clientWidth);53 if (i !== idx) { setIdx(i); resetZoom(); }54 }55 };56 const dist = () => {57 const [a, b] = [...pointers.current.values()];58 return Math.hypot(a.x - b.x, a.y - b.y);59 };60 const onPointerDown = (e: React.PointerEvent) => {61 pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });62 if (pointers.current.size === 2) pinch.current = { d: dist(), scale };63 if (pointers.current.size === 1) {64 const now = Date.now();65 if (now - lastTap.current < 300) { if (scale > 1) resetZoom(); else setScale(2.5); }66 lastTap.current = now;67 }68 };69 const onPointerMove = (e: React.PointerEvent) => {70 const prev = pointers.current.get(e.pointerId);71 if (!prev) return;72 pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY });73 if (pointers.current.size === 2 && pinch.current) {74 const s = Math.min(4, Math.max(1, pinch.current.scale * (dist() / pinch.current.d)));75 setScale(s);76 if (s === 1) { setTx(0); setTy(0); }77 } else if (pointers.current.size === 1 && scale > 1) {78 setTx((v) => v + (e.clientX - prev.x));79 setTy((v) => v + (e.clientY - prev.y));80 }81 };82 const onPointerUp = (e: React.PointerEvent) => {83 pointers.current.delete(e.pointerId);84 if (pointers.current.size < 2) pinch.current = null;85 };8687 return (88 <div className="lk-lightbox" role="dialog" aria-modal="true" aria-label={`Photos — ${titre}`}>89 <button type="button" className="lk-lightbox-close" aria-label="Fermer" onClick={onClose}><IcoClose size={20} /></button>90 <span className="lk-lightbox-count" aria-live="polite">{idx + 1} / {images.length}</span>91 <div className="lk-lightbox-track" ref={track} onScroll={onScroll}92 style={scale > 1 ? { overflow: "hidden", touchAction: "none" } : undefined}93 onPointerDown={onPointerDown} onPointerMove={onPointerMove}94 onPointerUp={onPointerUp} onPointerCancel={onPointerUp}>95 {images.map((u, i) => (96 <div className="lk-lightbox-cell" key={u}>97 <SmartImg src={u} original alt={`${titre} — photo ${i + 1} de ${images.length}`}98 draggable={false} loading={Math.abs(i - idx) <= 1 ? "eager" : "lazy"}99 style={i === idx && scale > 1 ? { transform: `translate(${tx}px, ${ty}px) scale(${scale})` } : undefined} />100 </div>101 ))}102 </div>103 {scale === 1 && idx > 0 && (104 <button type="button" className="lk-gallery-nav prev" aria-label="Photo précédente" onClick={() => go(-1)}><IcoChevronLeft size={20} /></button>105 )}106 {scale === 1 && idx < images.length - 1 && (107 <button type="button" className="lk-gallery-nav next" aria-label="Photo suivante" onClick={() => go(1)}><IcoChevronRight size={20} /></button>108 )}109 </div>110 );111}112113export default function PropertyGallery({ images, titre, unitType }:114 { images: string[]; titre: string; unitType?: string }) {115 const [idx, setIdx] = useState(0);116 const [zoom, setZoom] = useState(false);117 const track = useRef<HTMLDivElement>(null);118119 // préchargement discret de la photo suivante (miniature 800)120 useEffect(() => {121 const next = images[idx + 1];122 if (!next) return;123 const img = new Image();124 img.src = thumb(next, 800);125 }, [idx, images]);126127 const onScroll = () => {128 const el = track.current;129 if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));130 };131 const goto = (i: number) =>132 track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });133134 if (images.length === 0)135 return (136 <div className="lk-gallery" aria-label="Photos">137 <SmartImg src={null} fallbackLabel={unitType} alt="Aucune photo fournie par la source" />138 </div>139 );140141 return (142 <>143 <div className="lk-gallery" aria-roledescription="carrousel" aria-label="Photos du logement">144 <div className="lk-gallery-track" ref={track} onScroll={onScroll}>145 {images.map((u, i) => (146 <SmartImg key={u} src={u} width_={i === 0 ? 1280 : 800} fallbackLabel={unitType}147 loading={i === 0 ? "eager" : "lazy"} decoding="async"148 alt={`${titre} — photo ${i + 1} de ${images.length}`}149 onClick={() => setZoom(true)} />150 ))}151 </div>152 <span className="lk-gallery-count" aria-live="polite">{idx + 1} / {images.length}</span>153 <button type="button" className="lk-gallery-full" aria-label="Voir en plein écran" onClick={() => setZoom(true)}>154 <IcoExpand size={17} />155 </button>156 {idx > 0 && (157 <button type="button" className="lk-gallery-nav prev" aria-label="Photo précédente" onClick={() => goto(idx - 1)}><IcoChevronLeft size={20} /></button>158 )}159 {idx < images.length - 1 && (160 <button type="button" className="lk-gallery-nav next" aria-label="Photo suivante" onClick={() => goto(idx + 1)}><IcoChevronRight size={20} /></button>161 )}162 </div>163 {images.length > 1 && (164 <div className="lk-thumbs" role="list">165 {images.slice(0, 12).map((u, i) => (166 <button type="button" key={u} className={i === idx ? "on" : ""} onClick={() => goto(i)}167 aria-label={`Photo ${i + 1}`} aria-current={i === idx} role="listitem">168 <SmartImg src={u} width_={160} alt="" loading="lazy" decoding="async" />169 </button>170 ))}171 </div>172 )}173 {zoom && <Lightbox images={images} start={idx} titre={titre} onClose={() => setZoom(false)} />}174 </>175 );176}177