// ----------------------------------------------------------------------------- // Immo-Ka — Agrégateur de propriétés à vendre (province de Québec) // Auteur : Simon-Pierre Boucher — contact@spboucher.ai // fiche/PropertyGallery.tsx : galerie premium — grande image, balayage natif // (scroll-snap), compteur « 1 / 12 », légende de la source, bouton plein // écran, flèches au survol (desktop), vignettes ≥ 768 px, préchargement de // la photo suivante, lazy loading des autres. Les images qui ne chargent // pas sont retirées à la volée (jamais d'icône cassée) ; sans photo → // visuel de secours par type de bien (TypeFallback). Lightbox : balayage + // pincement pour zoomer + double tape (logique conservée de la fiche v2). // ----------------------------------------------------------------------------- import { useEffect, useRef, useState } from "react"; import { Ico } from "../components/Icons"; import { TypeFallback } from "../components/PropertyImg"; function Lightbox({ images, captions, start, titre, onClose }: { images: string[]; captions: string[]; start: number; titre: string; onClose: () => void }) { const [idx, setIdx] = useState(start); const [scale, setScale] = useState(1); const [tx, setTx] = useState(0); const [ty, setTy] = useState(0); const track = useRef(null); const pointers = useRef(new Map()); const pinch = useRef<{ d: number; scale: number } | null>(null); const lastTap = useRef(0); useEffect(() => { track.current?.scrollTo({ left: start * track.current.clientWidth }); document.documentElement.classList.add("ka-scroll-lock"); const onKey = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); if (e.key === "ArrowRight") go(1); if (e.key === "ArrowLeft") go(-1); }; window.addEventListener("keydown", onKey); return () => { document.documentElement.classList.remove("ka-scroll-lock"); window.removeEventListener("keydown", onKey); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); const resetZoom = () => { setScale(1); setTx(0); setTy(0); }; const go = (d: number) => { const el = track.current; if (!el) return; const i = Math.max(0, Math.min(images.length - 1, Math.round(el.scrollLeft / el.clientWidth) + d)); el.scrollTo({ left: i * el.clientWidth, behavior: "smooth" }); }; const onScroll = () => { const el = track.current; if (el && scale === 1) { const i = Math.round(el.scrollLeft / el.clientWidth); if (i !== idx) { setIdx(i); resetZoom(); } } }; const dist = () => { const [a, b] = [...pointers.current.values()]; return Math.hypot(a.x - b.x, a.y - b.y); }; const onPointerDown = (e: React.PointerEvent) => { pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (pointers.current.size === 2) pinch.current = { d: dist(), scale }; if (pointers.current.size === 1) { const now = Date.now(); if (now - lastTap.current < 300) { if (scale > 1) resetZoom(); else setScale(2.5); } lastTap.current = now; } }; const onPointerMove = (e: React.PointerEvent) => { const prev = pointers.current.get(e.pointerId); if (!prev) return; pointers.current.set(e.pointerId, { x: e.clientX, y: e.clientY }); if (pointers.current.size === 2 && pinch.current) { const s = Math.min(4, Math.max(1, pinch.current.scale * (dist() / pinch.current.d))); setScale(s); if (s === 1) { setTx(0); setTy(0); } } else if (pointers.current.size === 1 && scale > 1) { setTx((v) => v + (e.clientX - prev.x)); setTy((v) => v + (e.clientY - prev.y)); } }; const onPointerUp = (e: React.PointerEvent) => { pointers.current.delete(e.pointerId); if (pointers.current.size < 2) pinch.current = null; }; return (
{captions[idx] ? `${captions[idx]} · ` : ""}{idx + 1} / {images.length}
1 ? { overflow: "hidden", touchAction: "none" } : undefined} onPointerDown={onPointerDown} onPointerMove={onPointerMove} onPointerUp={onPointerUp} onPointerCancel={onPointerUp}> {images.map((u, i) => (
{`${titre} 1 ? { transform: `translate(${tx}px, ${ty}px) scale(${scale})` } : undefined} />
))}
{scale === 1 && idx > 0 && ( )} {scale === 1 && idx < images.length - 1 && ( )}
); } export default function PropertyGallery({ images, captions, titre, type }: { images: string[]; captions?: string[]; titre: string; type?: string }) { const [idx, setIdx] = useState(0); const [zoom, setZoom] = useState(false); const [dead, setDead] = useState>(new Set()); const track = useRef(null); // images qui ne chargent pas : retirées de la galerie à la volée ; légendes alignées const alive = images .map((u, i) => ({ u, cap: captions && captions.length === images.length ? captions[i] : "" })) .filter(({ u }) => !dead.has(u)); const markDead = (u: string) => setDead((d) => new Set(d).add(u)); const urls = alive.map((a) => a.u); const caps = alive.map((a) => a.cap); const cur = Math.min(idx, Math.max(0, alive.length - 1)); // préchargement discret de la photo suivante useEffect(() => { const next = urls[cur + 1]; if (!next) return; const img = new Image(); img.src = next; }, [cur, urls]); const onScroll = () => { const el = track.current; if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth)); }; const goto = (i: number) => track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" }); if (alive.length === 0) return (
); return ( <>
{alive.map(({ u }, i) => ( {`${titre} markDead(u)} onClick={() => setZoom(true)} /> ))}
{caps[cur] && {caps[cur]}} {cur + 1} / {alive.length} {cur > 0 && ( )} {cur < alive.length - 1 && ( )}
{alive.length > 1 && (
{alive.slice(0, 12).map(({ u }, i) => ( ))}
)} {zoom && setZoom(false)} />} ); }