SPB Git forge

spb/immo-ka

Public

Immo-Ka — agrégateur des propriétés à vendre au Québec (73 connecteurs, ~40 000 annonces, React+FastAPI)

112commits 1branches 0releases
125.4 MBsize
maindefault branch
13 days agolast push
Python 47.5% HTML 27.9% TypeScript 15.5% CSS 7.2% JavaScript 2%
8.6 KB · 187 lines tsx
Raw Blame History
1// -----------------------------------------------------------------------------2// Immo-Ka — Agrégateur de propriétés à vendre (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 », légende de la source, bouton plein6//   écran, flèches au survol (desktop), vignettes ≥ 768 px, préchargement de7//   la photo suivante, lazy loading des autres. Les images qui ne chargent8//   pas sont retirées à la volée (jamais d'icône cassée) ; sans photo →9//   visuel de secours par type de bien (TypeFallback). Lightbox : balayage +10//   pincement pour zoomer + double tape (logique conservée de la fiche v2).11// -----------------------------------------------------------------------------12import { useEffect, useRef, useState } from "react";13import { Ico } from "../components/Icons";14import { TypeFallback } from "../components/PropertyImg";1516function Lightbox({ images, captions, start, titre, onClose }:17                  { images: string[]; captions: 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="ik-lightbox" role="dialog" aria-modal="true" aria-label={`Photos — ${titre}`}>89      <button type="button" className="ik-lightbox-close" aria-label="Fermer" onClick={onClose}><Ico name="close" size={20} /></button>90      <span className="ik-lightbox-count" aria-live="polite">{captions[idx] ? `${captions[idx]} · ` : ""}{idx + 1} / {images.length}</span>91      <div className="ik-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="ik-lightbox-cell" key={u}>97            <img src={u} alt={`${titre} — photo ${i + 1} de ${images.length}`} draggable={false}98                 loading={Math.abs(i - idx) <= 1 ? "eager" : "lazy"} decoding="async"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="ik-gallery-nav prev" aria-label="Photo précédente" onClick={() => go(-1)}><Ico name="chevleft" size={20} /></button>105      )}106      {scale === 1 && idx < images.length - 1 && (107        <button type="button" className="ik-gallery-nav next" aria-label="Photo suivante" onClick={() => go(1)}><Ico name="chevright" size={20} /></button>108      )}109    </div>110  );111}112113export default function PropertyGallery({ images, captions, titre, type }:114    { images: string[]; captions?: string[]; titre: string; type?: string }) {115  const [idx, setIdx] = useState(0);116  const [zoom, setZoom] = useState(false);117  const [dead, setDead] = useState<Set<string>>(new Set());118  const track = useRef<HTMLDivElement>(null);119120  // images qui ne chargent pas : retirées de la galerie à la volée ; légendes alignées121  const alive = images122    .map((u, i) => ({ u, cap: captions && captions.length === images.length ? captions[i] : "" }))123    .filter(({ u }) => !dead.has(u));124  const markDead = (u: string) => setDead((d) => new Set(d).add(u));125  const urls = alive.map((a) => a.u);126  const caps = alive.map((a) => a.cap);127  const cur = Math.min(idx, Math.max(0, alive.length - 1));128129  // préchargement discret de la photo suivante130  useEffect(() => {131    const next = urls[cur + 1];132    if (!next) return;133    const img = new Image();134    img.src = next;135  }, [cur, urls]);136137  const onScroll = () => {138    const el = track.current;139    if (el) setIdx(Math.round(el.scrollLeft / el.clientWidth));140  };141  const goto = (i: number) =>142    track.current?.scrollTo({ left: i * track.current.clientWidth, behavior: "smooth" });143144  if (alive.length === 0)145    return (146      <div className="ik-gallery ik-gallery-empty" aria-label="Photos">147        <TypeFallback type={type} />148      </div>149    );150151  return (152    <>153      <div className="ik-gallery" aria-roledescription="carrousel" aria-label="Photos de la propriété">154        <div className="ik-gallery-track" ref={track} onScroll={onScroll}>155          {alive.map(({ u }, i) => (156            <img key={u} src={u} loading={i <= 1 ? "eager" : "lazy"} decoding="async"157                 alt={`${titre} — photo ${i + 1} de ${alive.length}`}158                 onError={() => markDead(u)} onClick={() => setZoom(true)} />159          ))}160        </div>161        {caps[cur] && <span className="ik-gallery-caption">{caps[cur]}</span>}162        <span className="ik-gallery-count" aria-live="polite">{cur + 1} / {alive.length}</span>163        <button type="button" className="ik-gallery-full" aria-label="Voir en plein écran" onClick={() => setZoom(true)}>164          <Ico name="expand" size={17} />165        </button>166        {cur > 0 && (167          <button type="button" className="ik-gallery-nav prev" aria-label="Photo précédente" onClick={() => goto(cur - 1)}><Ico name="chevleft" size={20} /></button>168        )}169        {cur < alive.length - 1 && (170          <button type="button" className="ik-gallery-nav next" aria-label="Photo suivante" onClick={() => goto(cur + 1)}><Ico name="chevright" size={20} /></button>171        )}172      </div>173      {alive.length > 1 && (174        <div className="ik-thumbs" role="list">175          {alive.slice(0, 12).map(({ u }, i) => (176            <button type="button" key={u} className={i === cur ? "on" : ""} onClick={() => goto(i)}177                    aria-label={`Photo ${i + 1}`} aria-current={i === cur} role="listitem">178              <img src={u} alt="" loading="lazy" decoding="async" onError={() => markDead(u)} />179            </button>180          ))}181        </div>182      )}183      {zoom && <Lightbox images={urls} captions={caps} start={cur} titre={titre} onClose={() => setZoom(false)} />}184    </>185  );186}187