dave commited on
Commit
f6b2c26
·
verified ·
1 Parent(s): d579b7a

feat: poster match shows poster only + admin OTP 20 chars + FR/EN toggle

Browse files
backend/app/models.py CHANGED
@@ -48,6 +48,9 @@ class Cat(Base):
48
  # lost | found | reunited
49
  status: Mapped[str] = mapped_column(String(20), default="lost")
50
 
 
 
 
51
  # CONFIDENTIALITÉ : code secret remis au propriétaire à la déclaration. Seul
52
  # moyen d'accéder à SA fiche et à SES signalements reçus. Jamais exposé ailleurs.
53
  claim_token: Mapped[str | None] = mapped_column(
 
48
  # lost | found | reunited
49
  status: Mapped[str] = mapped_column(String(20), default="lost")
50
 
51
+ # owner | poster (affiches partagées par des passants, sans compte propriétaire)
52
+ source: Mapped[str] = mapped_column(String(20), default="owner")
53
+
54
  # CONFIDENTIALITÉ : code secret remis au propriétaire à la déclaration. Seul
55
  # moyen d'accéder à SA fiche et à SES signalements reçus. Jamais exposé ailleurs.
56
  claim_token: Mapped[str | None] = mapped_column(
backend/app/routers/admin.py CHANGED
@@ -52,7 +52,7 @@ def admin_request_code(db: Session = Depends(get_db)):
52
  raise HTTPException(403, "Admin désactivé (CHATS_ADMIN_KEY non configuré)")
53
  for old in db.scalars(select(models.EmailOTP).where(models.EmailOTP.email == _OTP_KEY)):
54
  db.delete(old)
55
- code = f"{secrets.randbelow(1_000_000):06d}"
56
  db.add(models.EmailOTP(
57
  email=_OTP_KEY, code_hash=_hash(code),
58
  expires_at=dt.datetime.now(dt.timezone.utc) + dt.timedelta(minutes=config.OTP_TTL_MIN),
 
52
  raise HTTPException(403, "Admin désactivé (CHATS_ADMIN_KEY non configuré)")
53
  for old in db.scalars(select(models.EmailOTP).where(models.EmailOTP.email == _OTP_KEY)):
54
  db.delete(old)
55
+ code = secrets.token_hex(10) # 20 caractères hexadécimaux
56
  db.add(models.EmailOTP(
57
  email=_OTP_KEY, code_hash=_hash(code),
58
  expires_at=dt.datetime.now(dt.timezone.utc) + dt.timedelta(minutes=config.OTP_TTL_MIN),
backend/app/routers/cats.py CHANGED
@@ -94,11 +94,13 @@ def create_cat(
94
  phone: str | None = Form(None),
95
  last_seen_lat: float | None = Form(None),
96
  last_seen_lng: float | None = Form(None),
 
97
  photos: list[UploadFile] = File(default=[]),
98
  db: Session = Depends(get_db),
99
  ):
100
  """Déclare un chat perdu (email = identifiant pour le suivi ; téléphone optionnel,
101
- privé). Plusieurs photos fortement recommandées."""
 
102
  storage.guard_count(photos)
103
  cat = models.Cat(
104
  name=name, description=description, color=color, pattern=pattern, breed=breed,
@@ -109,6 +111,7 @@ def create_cat(
109
  last_seen_lat=last_seen_lat, last_seen_lng=last_seen_lng,
110
  last_seen_at=dt.datetime.now(dt.timezone.utc),
111
  claim_token=secrets.token_urlsafe(16),
 
112
  )
113
  for f in photos:
114
  if f and f.filename:
 
94
  phone: str | None = Form(None),
95
  last_seen_lat: float | None = Form(None),
96
  last_seen_lng: float | None = Form(None),
97
+ source: str | None = Form(None),
98
  photos: list[UploadFile] = File(default=[]),
99
  db: Session = Depends(get_db),
100
  ):
101
  """Déclare un chat perdu (email = identifiant pour le suivi ; téléphone optionnel,
102
+ privé). Plusieurs photos fortement recommandées.
103
+ `source=poster` signale une affiche partagée par un passant (sans compte propriétaire)."""
104
  storage.guard_count(photos)
105
  cat = models.Cat(
106
  name=name, description=description, color=color, pattern=pattern, breed=breed,
 
111
  last_seen_lat=last_seen_lat, last_seen_lng=last_seen_lng,
112
  last_seen_at=dt.datetime.now(dt.timezone.utc),
113
  claim_token=secrets.token_urlsafe(16),
114
+ source="poster" if source == "poster" else "owner",
115
  )
116
  for f in photos:
117
  if f and f.filename:
backend/app/schemas.py CHANGED
@@ -92,6 +92,7 @@ class CandidateOut(BaseModel):
92
  pattern: str | None
93
  confidence: float
94
  photos: list[PhotoOut] = []
 
95
 
96
 
97
  class SightingMatchResult(BaseModel):
 
92
  pattern: str | None
93
  confidence: float
94
  photos: list[PhotoOut] = []
95
+ is_poster: bool = False # True = affiche partagée (pas de compte propriétaire)
96
 
97
 
98
  class SightingMatchResult(BaseModel):
backend/app/serialize.py CHANGED
@@ -105,6 +105,7 @@ def candidate_out(cat: models.Cat, confidence: float,
105
  # On ne montre que quelques photos (assez pour reconnaître) — pas tout
106
  # l'enrôlement : moins d'exposition, payload plus léger.
107
  photos=[photo_out(p) for p in cat.photos[:3]],
 
108
  )
109
 
110
 
 
105
  # On ne montre que quelques photos (assez pour reconnaître) — pas tout
106
  # l'enrôlement : moins d'exposition, payload plus léger.
107
  photos=[photo_out(p) for p in cat.photos[:3]],
108
+ is_poster=getattr(cat, "source", "owner") == "poster",
109
  )
110
 
111
 
backend/migrations/versions/a3f1c8e92d47_add_cat_source.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """add cat source column
2
+
3
+ Revision ID: a3f1c8e92d47
4
+ Revises: 5c7fa081b68a
5
+ Create Date: 2026-06-12 00:00:00.000000
6
+
7
+ """
8
+ from typing import Sequence, Union
9
+
10
+ from alembic import op
11
+ import sqlalchemy as sa
12
+
13
+
14
+ # revision identifiers, used by Alembic.
15
+ revision: str = 'a3f1c8e92d47'
16
+ down_revision: Union[str, Sequence[str], None] = '5c7fa081b68a'
17
+ branch_labels: Union[str, Sequence[str], None] = None
18
+ depends_on: Union[str, Sequence[str], None] = None
19
+
20
+
21
+ def upgrade() -> None:
22
+ with op.batch_alter_table('cats', schema=None) as batch_op:
23
+ batch_op.add_column(sa.Column('source', sa.String(20), nullable=False, server_default='owner'))
24
+
25
+
26
+ def downgrade() -> None:
27
+ with op.batch_alter_table('cats', schema=None) as batch_op:
28
+ batch_op.drop_column('source')
web/index.html CHANGED
@@ -17,7 +17,11 @@
17
  padding:14px 16px;display:flex;align-items:center;gap:10px;box-shadow:var(--shadow)}
18
  header .back{background:rgba(255,255,255,.2);border:none;color:#fff;font-size:20px;width:40px;height:40px;
19
  border-radius:10px;display:none}
20
- header h1{font-size:19px;margin:0;font-weight:800;letter-spacing:.2px}
 
 
 
 
21
  main{max-width:680px;margin:0 auto;padding:16px}
22
  .sub{color:var(--muted);font-size:15px;margin:2px 0 18px}
23
  /* Home action cards */
@@ -98,9 +102,8 @@
98
  #toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:#16202c;color:#fff;
99
  padding:12px 18px;border-radius:24px;opacity:0;transition:.3s;z-index:99;font-weight:600;max-width:90%}
100
  #toast.show{opacity:1}
101
- /* Visionneuse plein écran (cliquer une photo pour la voir en grand) */
102
  .candidate img,.lead img,.game-pair img{cursor:zoom-in}
103
- /* Affordance mobile : badge loupe en coin de la vignette agrandissable */
104
  .zwrap{position:relative;display:inline-block;flex:none;line-height:0}
105
  .zwrap .zbadge{position:absolute;right:4px;bottom:4px;background:rgba(8,12,18,.66);color:#fff;
106
  border-radius:9px;font-size:12px;padding:1px 6px;pointer-events:none;line-height:1.5}
@@ -115,13 +118,13 @@
115
  footer{max-width:680px;margin:8px auto 28px;padding:18px 16px 0;border-top:1px solid var(--line);
116
  text-align:center;color:var(--muted);font-size:12.5px;line-height:1.7}
117
  footer a{color:var(--muted);text-decoration:underline;cursor:pointer}
118
- /* Page légale : texte plus dense et lisible */
119
  .legal p{font-size:14.5px;color:#2b3a48;margin:10px 0}
120
  .legal ul{font-size:14.5px;color:#2b3a48;padding-left:18px;margin:8px 0}
121
  .legal li{margin:7px 0}
122
  .legal a{color:var(--accent2)}
123
- /* FAQ — discrète et repliée par défaut (priorité aux actions d'urgence) */
124
- .faq{margin-top:168px}
125
  .faq h3{font-size:12px;font-weight:700;color:var(--muted);letter-spacing:.6px;text-transform:uppercase;margin:0 0 2px}
126
  .faq details{border-bottom:1px solid var(--line)}
127
  .faq summary{font-weight:600;cursor:pointer;font-size:14px;color:var(--ink);list-style:none;display:flex;gap:9px;padding:12px 2px}
@@ -136,64 +139,65 @@
136
  <header>
137
  <button class="back" id="backBtn" onclick="goHome()">‹</button>
138
  <h1 id="hTitle">🐱 Chats perdus (version Beta)</h1>
 
 
 
 
139
  </header>
140
  <main>
141
 
142
  <!-- ===================== ACCUEIL ===================== -->
143
  <section id="home">
144
- <p class="sub">Entraide communautaire pour retrouver nos compagnons.</p>
145
 
146
- <!-- Deux flux d'urgence, massifs -->
147
  <div class="home">
148
  <button class="action hero hero-lost" onclick="show('report')">
149
  <div class="ico">🚨</div><div>
150
- <span class="tag tag-lost">Propriétaire</span>
151
- <div class="t">J'ai perdu mon chat</div>
152
- <div class="d">Déclare sa disparition en envoyant quelques photos (et son affiche, si tu en as une). Si un passant le croise et le prend en photo, notre IA le reconnaîtra.<br><br>
153
  • Alerte immédiate : Tu reçois un courriel dès qu'il est repéré.<br>
154
  • Vie privée protégée : Tu es le seul à décider de contacter le trouveur. Ton courriel et tes coordonnées ne sont jamais divulgués.</div></div>
155
  </button>
156
  <button class="action hero hero-seen" onclick="show('check')">
157
  <div class="ico">📷</div><div>
158
- <span class="tag tag-seen">Passant / Trouveur</span>
159
- <div class="t">J'ai vu un chat</div>
160
- <div class="d">Prends une photo du chat croisé qui semble perdu. L'IA compare instantanément sa ressemblance avec notre base de données et te montre les profils similaires.<br><br>
161
  • Signalement rapide : Si tu reconnais le chat, valide la correspondance pour envoyer une notification automatique et anonyme à sa famille.</div></div>
162
  </button>
163
  <button class="action hero hero-follow" onclick="show('follow')">
164
  <div class="ico">ℹ️</div><div>
165
- <span class="tag tag-follow">Suivi</span>
166
- <div class="t">Suivre mon signalement</div>
167
- <div class="d">Accède à ton espace pour gérer ton alerte ou clôturer la recherche.</div></div>
168
  </button>
169
  </div>
170
 
171
- <!-- Actions communautaires, secondaires -->
172
- <p class="sub" style="margin-top:24px;margin-bottom:8px">Aider la communauté :</p>
173
  <div class="secondary">
174
  <button class="action mini" onclick="show('poster')">
175
- <div class="ico">📋</div><div><div class="t">Partager une affiche vue</div>
176
- <div class="d">Enrichir la banque</div></div></button>
177
  <button class="action mini" onclick="show('train')">
178
- <div class="ico">🧠</div><div><div class="t">Aider l'IA</div>
179
- <div class="d">Photos + jeu de reconnaissance</div></div></button>
180
  </div>
181
 
182
- <!-- FAQ -->
183
  <div class="faq">
184
- <h3>Questions fréquentes</h3>
185
- <details><summary>Comment ça fonctionne&nbsp;?</summary>
186
- <p>Tu déclares ton chat perdu avec ses photos. Quand quelqu'un croise un chat, il en
187
  prend une photo : l'IA la compare aux chats perdus et montre les ressemblances. Si la
188
  personne reconnaît ton chat, tu reçois un signalement par courriel.</p></details>
189
- <details><summary>Mes informations restent-elles privées&nbsp;?</summary>
190
- <p>Oui. Aucun annuaire public : la personne qui repère un chat ne voit jamais tes
191
  coordonnées. On te rejoint par un code envoyé à ton courriel, et tu peux tout
192
  supprimer quand tu veux.</p></details>
193
- <details><summary>Est-ce gratuit&nbsp;?</summary>
194
- <p>Oui, entièrement. C'est un projet d'entraide communautaire, sans publicité.</p></details>
195
- <details><summary>Quelle technologie derrière&nbsp;?</summary>
196
- <p>Allo Chat utilise <a href="https://huggingface.co/conservationxlabs/miewid-msv3" target="_blank" rel="noopener noreferrer">MiewID</a>, un modèle d'IA de ré-identification animale qui transforme
197
  chaque photo en une « empreinte » visuelle. On compare ces empreintes pour repérer les
198
  chats qui se ressemblent, puis un humain confirme toujours la correspondance.</p></details>
199
  </div>
@@ -201,62 +205,62 @@
201
 
202
  <!-- ===================== 1. VÉRIFIER ===================== -->
203
  <section id="check" class="hidden">
204
- <div class="privacy">🔒 On ne montre que les chats qui ressemblent à ta photo. Le contact du
205
  propriétaire reste privé — c'est lui qui te recontacte.</div>
206
  <div class="card">
207
- <h2>Tu as croisé un chat&nbsp;?</h2>
208
- <label>Photo du chat</label>
209
- <label class="photo-btn" for="c_photo">📷 Prendre / choisir une photo</label>
210
  <input class="file-hidden" id="c_photo" type="file" accept="image/*" onchange="prev('c_photo','c_prev')" />
211
  <div class="thumbs" id="c_prev"></div>
212
- <div class="hint" id="c_gps">📍 Position : non captée (optionnel, aide au tri)</div>
213
- <button class="btn btn-ghost" onclick="getPos('c')">Capter ma position</button>
214
- <button class="btn btn-go" onclick="doCheck()">🔍 Vérifier</button>
215
  <div class="search-prog" id="c_prog"><div class="bar"><div id="c_prog_fill"></div></div>
216
  <div class="lbl"><span class="dot"></span><span id="c_prog_txt"></span></div></div>
217
  </div>
218
  <div id="c_result"></div>
219
  <div id="c_leadbox" class="card hidden">
220
- <h2 id="c_leadtitle">Prévenir le propriétaire 🐾</h2>
221
- <p class="hint">Laisse TES coordonnées : le propriétaire te recontactera.</p>
222
- <label>Ton contact (téléphone / courriel)</label>
223
  <input id="c_contact" placeholder="514-555-1234" />
224
- <label>Message (où, quand, état du chat)</label>
225
- <textarea id="c_msg" rows="2" placeholder="Vu ce matin près du parc"></textarea>
226
- <button class="btn btn-primary" onclick="sendLead()">Envoyer au propriétaire</button>
227
  </div>
228
  </section>
229
 
230
  <!-- ===================== 2. SIGNALER ===================== -->
231
  <section id="report" class="hidden">
232
- <div class="privacy">🔒 Ton contact reste privé. Tu reçois un <b>code secret</b> pour suivre les
233
  signalements. Plus tu mets de photos, mieux l'IA reconnaît ton chat.</div>
234
  <div class="card">
235
- <h2>Signaler mon chat perdu</h2>
236
- <label>Tu as une affiche&nbsp;? (photo ou PDF — optionnel)</label>
237
- <label class="photo-btn" for="r_poster">🖼️ Charger l'affiche (remplit le formulaire)</label>
238
  <input class="file-hidden" id="r_poster" type="file" accept="image/*,application/pdf" onchange="posterPrefill('r_poster')" />
239
  <div class="hint" id="r_poster_note"></div>
240
 
241
- <label>Nom du chat</label>
242
- <input id="r_name" placeholder="Tigrou" />
243
- <label>Description (robe, signes distinctifs)</label>
244
- <textarea id="r_desc" rows="2" placeholder="Tortie, médaillon blanc, timide"></textarea>
245
- <label>Ton courriel (sert à suivre les signalements — jamais montré aux autres)</label>
246
- <input id="r_email" type="email" placeholder="toi@exemple.com" />
247
- <label>Ton téléphone (optionnel, privé)</label>
248
  <input id="r_phone" type="tel" placeholder="514-555-1234" />
249
- <label>Détails supplémentaires (optionnel)</label>
250
- <textarea id="r_details" rows="2" placeholder="Disparu le 3 juin, secteur Rosemont, collier bleu…"></textarea>
251
 
252
- <label>Photos du chat — <b>5 à 10 recommandé</b> 🎯</label>
253
- <label class="photo-btn" for="r_photos">📷 Ajouter des photos du chat</label>
254
  <input class="file-hidden" id="r_photos" type="file" accept="image/*" multiple onchange="prev('r_photos','r_prev'); photoHint()" />
255
  <div class="thumbs" id="r_prev"></div>
256
- <div class="hint" id="r_hint">Astuce : plusieurs angles et éclairages = bien plus fiable.</div>
257
- <div class="hint" id="r_gps">📍 Dernière position connue : non captée</div>
258
- <button class="btn btn-ghost" onclick="getPos('r')">Capter la position</button>
259
- <button class="btn btn-primary" onclick="doReport()">🚨 Déclarer (obtenir mon code)</button>
260
  <div class="search-prog" id="r_prog"><div class="bar"><div id="r_prog_fill"></div></div>
261
  <div class="lbl"><span class="dot"></span><span id="r_prog_txt"></span></div></div>
262
  <div id="r_token"></div>
@@ -265,18 +269,18 @@
265
 
266
  <!-- ===================== SUIVRE UN SIGNALEMENT ===================== -->
267
  <section id="follow" class="hidden">
268
- <div class="privacy">🔒 Accès sécurisé : on t'envoie un code par courriel pour vérifier
269
  que c'est bien toi. Tu vois alors quand tu as déclaré ton chat, combien de fois l'IA
270
  l'a comparé, et les signalements reçus.</div>
271
  <div class="card">
272
- <h2>Suivre mon signalement</h2>
273
- <label>Ton courriel (celui de la déclaration)</label>
274
- <input id="r_track_email" type="email" placeholder="toi@exemple.com" />
275
- <button class="btn btn-ghost" onclick="requestCode()">📩 Recevoir mon code</button>
276
  <div id="r_code_box" class="hidden">
277
- <label>Code reçu</label>
278
  <input id="r_track_code" inputmode="numeric" placeholder="123456" />
279
- <button class="btn btn-go" onclick="verifyCode()">Voir mon suivi</button>
280
  </div>
281
  <div id="r_track"></div>
282
  </div>
@@ -284,51 +288,51 @@
284
 
285
  <!-- ===================== 3. PARTAGER UNE AFFICHE ===================== -->
286
  <section id="poster" class="hidden">
287
- <div class="privacy">📋 Les affiches sont publiques. En les partageant, tu enrichis la banque et
288
  aides d'autres personnes à retrouver leur chat.</div>
289
  <div class="card">
290
- <h2>Partager une affiche trouvée</h2>
291
- <label>Photo ou PDF de l'affiche</label>
292
- <label class="photo-btn" for="p_file">📷 Prendre / charger l'affiche</label>
293
  <input class="file-hidden" id="p_file" type="file" accept="image/*,application/pdf" onchange="prev('p_file','p_prev')" />
294
  <div class="thumbs" id="p_prev"></div>
295
- <label>Infos visibles sur l'affiche (optionnel)</label>
296
- <textarea id="p_text" rows="3" placeholder="Nom, secteur, description… (ce qui est écrit sur l'affiche)"></textarea>
297
- <div class="hint" id="p_gps">📍 Où l'as-tu vue ? (optionnel)</div>
298
- <button class="btn btn-ghost" onclick="getPos('p')">Capter la position</button>
299
- <button class="btn btn-go" onclick="doPoster()">📋 Ajouter à la banque</button>
300
  <div id="p_result"></div>
301
  </div>
302
  </section>
303
 
304
  <!-- ===================== 4. ENTRAÎNER L'IA ===================== -->
305
  <section id="train" class="hidden">
306
- <div class="privacy">🧠 Aide volontaire. Ces photos servent UNIQUEMENT à entraîner l'IA — elles
307
  ne sont jamais mêlées aux fiches privées de chats perdus.</div>
308
  <div class="card">
309
- <h2>Donner des photos</h2>
310
- <p class="hint">Ajoute des photos de chats (le tien, ou des chats croisés) pour aider l'IA à mieux reconnaître.</p>
311
- <label class="photo-btn" for="t_photos">📷 Ajouter des photos</label>
312
  <input class="file-hidden" id="t_photos" type="file" accept="image/*" multiple onchange="prev('t_photos','t_prev')" />
313
  <div class="thumbs" id="t_prev"></div>
314
  <label style="display:flex;gap:10px;align-items:flex-start;font-weight:400;font-size:13.5px;margin-top:12px">
315
  <input id="t_consent" type="checkbox" style="width:auto;margin-top:2px" />
316
- <span>J'accepte que ces images soient utilisées pour <b>entraîner le modèle de
317
  reconnaissance</b>. Elles ne seront pas mêlées aux fiches de chats perdus.</span>
318
  </label>
319
- <button class="btn btn-go" onclick="doContribute()">Donner ces photos</button>
320
  </div>
321
  <div class="card">
322
- <h2>Jouer : même chat ou pas&nbsp;?</h2>
323
- <p class="hint">Regarde deux photos et dis si c'est le même chat. Chaque réponse aide l'IA.</p>
324
- <div id="game"><button class="btn btn-go" onclick="nextPair()">Commencer à jouer</button></div>
325
  <div class="hint" id="t_stats"></div>
326
  </div>
327
  </section>
328
 
329
  <!-- ===================== AVIS LÉGAL & CONFIDENTIALITÉ ===================== -->
330
  <section id="legal" class="hidden">
331
- <div class="card legal">
332
  <h2>Avis de non-responsabilité</h2>
333
  <p>Allo Chat est un service communautaire <b>gratuit</b> d'entraide pour aider à
334
  retrouver des chats perdus, à Montréal et ailleurs.</p>
@@ -346,7 +350,6 @@
346
  <p><b>Conduite des utilisateurs.</b> Vous êtes responsable des renseignements que vous
347
  publiez et de vos échanges. Faites preuve de prudence lors d'un contact avec une
348
  personne inconnue, et ne tentez pas d'approcher un animal craintif ou en détresse.</p>
349
-
350
  <h2 style="margin-top:22px">Confidentialité — Loi 25 (Québec)</h2>
351
  <p>Allo Chat respecte la <i>Loi sur la protection des renseignements personnels dans le
352
  secteur privé</i> (Loi 25). Voici l'essentiel.</p>
@@ -386,7 +389,7 @@
386
 
387
  </main>
388
 
389
- <footer>
390
  🐾 Fait à Montréal · © 2026 Allo Chat ·
391
  <a onclick="show('legal')">Avis légal &amp; confidentialité</a><br>
392
  Contact : <a href="mailto:info@allochat.org">info@allochat.org</a>
@@ -396,24 +399,410 @@
396
  <div id="lightbox" onclick="closeZoom(event)">
397
  <button class="x" onclick="closeZoom(event)" aria-label="Fermer">✕</button>
398
  <img id="lightbox_img" alt="Photo du chat en grand">
399
- <div class="hint-zoom">Touche l'image ou appuie sur ✕ pour fermer</div>
400
  </div>
401
 
402
  <script>
403
  const API = location.origin;
404
  const pos = {c:null,r:null,p:null};
405
  let lastSightingId=null, chosenCandidateToken=null, gamePair=null;
406
- let lastMatches=[], ownerCats=[]; // données serveur conservées côté JS (pas dans le DOM)
407
 
408
- // Échappement HTML : noms/descriptions/messages viennent d'inconnus → jamais
409
- // injectés bruts dans innerHTML (anti-XSS). Sert pour tout texte affiché.
410
  const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>(
411
  {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
412
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
413
  // ---- navigation ----
414
- const TITLES={home:'🐱 Chats perdus (version Beta)',check:'📷 J\'ai vu un chat',report:'🚨 J\'ai perdu mon chat',
415
- follow:'ℹ️ Suivre mon signalement',poster:'📋 Partager une affiche',train:'🧠 Aider l\'IA',
416
- legal:'⚖️ Avis légal & confidentialité'};
 
 
 
417
  function show(v){
418
  ['home','check','report','follow','poster','train','legal'].forEach(id=>document.getElementById(id).classList.toggle('hidden',id!==v));
419
  document.getElementById('hTitle').textContent=TITLES[v];
@@ -424,21 +813,19 @@
424
  function goHome(){ show('home'); }
425
 
426
  function toast(m){const t=document.getElementById('toast');t.textContent=m;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),2800);}
427
- // Visionneuse : agrandit une photo. On lit l'URL depuis l'élément <img>
428
- // cliqué (this.src) — aucune donnée n'est injectée dans un handler.
429
  function zoom(src){ document.getElementById('lightbox_img').src=src;
430
  document.getElementById('lightbox').classList.add('on'); }
431
- // Vignette agrandissable + badge loupe (cue visible y compris sur mobile).
432
  const zimg=url=>`<span class="zwrap"><img src="${encodeURI(url)}" onclick="zoom(this.src)" title="Voir en grand"><span class="zbadge">🔍</span></span>`;
433
  function closeZoom(e){ if(e) e.stopPropagation();
434
  const lb=document.getElementById('lightbox'); lb.classList.remove('on');
435
  document.getElementById('lightbox_img').removeAttribute('src'); }
436
  document.addEventListener('keydown',e=>{ if(e.key==='Escape') closeZoom(); });
437
  const val=id=>document.getElementById(id).value;
438
- function getPos(p){ if(!navigator.geolocation) return toast('Géolocalisation indisponible');
 
439
  navigator.geolocation.getCurrentPosition(g=>{pos[p]={lat:g.coords.latitude,lng:g.coords.longitude};
440
  document.getElementById(p+'_gps').textContent=`📍 ${g.coords.latitude.toFixed(5)}, ${g.coords.longitude.toFixed(5)}`;
441
- },()=>toast('Position refusée'));
442
  }
443
  function prev(inputId,boxId){
444
  const box=document.getElementById(boxId); box.innerHTML='';
@@ -448,14 +835,10 @@
448
  }
449
  }
450
 
451
- // ---------- Progression des recherches (discrète mais réelle) ----------
452
- // Nom du moteur IA réellement actif (depuis /health) — pour des messages honnêtes.
453
- let AI_NAME='l\'IA';
454
  fetch(API+'/health').then(r=>r.json()).then(h=>{
455
  const e=(h.embedder||'');
456
- AI_NAME = e.includes('miewid') ? 'MiewID (ré-identification animale)'
457
- : e.includes('megadescriptor') ? 'MegaDescriptor'
458
- : 'l\'analyse colorimétrique';
459
  }).catch(()=>{});
460
 
461
  function progress(id){
@@ -464,10 +847,9 @@
464
  let timer=null, finished=false;
465
  const set=(p,t)=>{ fill.style.width=Math.min(p,98)+'%'; if(t) txt.textContent=t; };
466
  return {
467
- show(){ finished=false; box.classList.add('on'); set(2,'Préparation…'); },
468
  set,
469
- creep(target,t){ // avance doucement vers `target` pendant que le serveur travaille
470
- if(finished) return; if(t) txt.textContent=t;
471
  clearInterval(timer);
472
  timer=setInterval(()=>{ if(finished) return clearInterval(timer);
473
  const cur=parseFloat(fill.style.width)||0;
@@ -481,14 +863,13 @@
481
  };
482
  }
483
 
484
- // POST avec vraie progression d'envoi (XHR) puis étapes serveur.
485
  function postWithProgress(url, fd, prog, stages){
486
  return new Promise((resolve,reject)=>{
487
  const xhr=new XMLHttpRequest();
488
  xhr.open('POST', url);
489
  xhr.upload.onprogress=e=>{ if(e.lengthComputable){
490
  const pct=e.loaded/e.total;
491
- prog.set(2+Math.round(pct*38), `📤 Envoi… ${Math.round(pct*100)}%`); } };
492
  xhr.upload.onload=()=>{ prog.creep(72, stages[0]);
493
  if(stages[1]) setTimeout(()=>prog.creep(93, stages[1]), 2600); };
494
  xhr.onload=()=>resolve({ok:xhr.status>=200&&xhr.status<300, status:xhr.status,
@@ -501,55 +882,65 @@
501
  // ---------- 1. VÉRIFIER ----------
502
  async function doCheck(){
503
  const f=document.getElementById('c_photo').files[0];
504
- if(!f) return toast('Ajoute une photo du chat');
505
  const fd=new FormData(); fd.append('photos',f);
506
  if(pos.c){fd.append('lat',pos.c.lat);fd.append('lng',pos.c.lng);}
507
  const prog=progress('c_prog'); prog.show();
 
508
  let r;
509
  try{
510
  r=await postWithProgress(API+'/sightings', fd, prog, [
511
- `🧠 ${AI_NAME} crée l'empreinte visuelle du chat…`,
512
- '🔎 Comparaison avec les chats perdus enregistrés…']);
513
- }catch(e){ return prog.fail('Erreur réseau — réessaie'); }
514
- if(!r.ok){ return prog.fail('Erreur '+r.status+(r.status===429?' — trop de requêtes, patiente une minute':'')); }
515
- prog.done('✅ Analyse terminée');
516
  const d=r.json(); lastSightingId=d.sighting_id; lastMatches=d.matches||[];
517
  const box=document.getElementById('c_result'); document.getElementById('c_leadbox').classList.add('hidden');
518
  if(!d.match_found||!d.matches.length){
519
- box.innerHTML=`<div class="result none"><div class="big">🔍</div><b>Aucune correspondance</b>
520
  <p class="hint">${esc(d.message)}</p></div>`; return;
521
  }
522
- box.innerHTML=`<div class="card"><b>${d.matches.length} chat(s) qui ressemble(nt)</b>
523
- <p class="hint">Reconnais-tu l'un d'eux&nbsp;?</p>`+
524
  d.matches.map(m=>{
525
  const pct=Math.round(m.confidence*100);
526
- const c=pct>=70?['Forte','var(--ok)']:pct>=40?['Moyenne','var(--warn)']:['Faible','var(--bad)'];
527
  const ph=(m.photos[0]&&m.photos[0].url)?API+m.photos[0].url:'';
 
 
 
528
  return `<div class="candidate">${ph?zimg(ph):''}<div style="flex:1">
529
- <b>${esc(m.name||'Chat perdu')}</b> <span class="pill" style="background:${c[1]}">${c[0]} ${pct}%</span><br>
530
  <span class="hint">${esc(m.description||'')}</span><br>
531
- <button class="btn btn-primary" style="margin-top:8px" onclick="chooseCat('${m.candidate_token}')">C'est lui ✅</button>
532
  </div></div>`;
533
  }).join('')+`</div>`;
534
  }
535
  function chooseCat(id){ chosenCandidateToken=id;
536
  const m=lastMatches.find(x=>x.candidate_token===id);
537
- const name=(m&&m.name)?m.name:'ce chat';
538
- document.getElementById('c_leadtitle').textContent=`Tu reconnais ${name} ? Préviens le propriétaire 🐾`;
539
  const b=document.getElementById('c_leadbox'); b.classList.remove('hidden'); b.scrollIntoView({behavior:'smooth'});
540
  }
 
 
 
 
 
 
541
  async function sendLead(){
542
- if(!chosenCandidateToken) return toast('Choisis un chat');
543
  const r=await fetch(`${API}/sightings/${lastSightingId}/lead`,{method:'POST',headers:{'Content-Type':'application/json'},
544
  body:JSON.stringify({candidate_token:chosenCandidateToken,finder_contact:val('c_contact'),message:val('c_msg')})});
545
  if(!r.ok) return toast('Erreur '+r.status);
546
- document.getElementById('c_leadbox').innerHTML='<div class="result match"><div class="big">🙏</div><b>Merci !</b><p class="hint">Le propriétaire a reçu ton signalement.</p></div>';
547
  }
548
 
549
  // ---------- 2. SIGNALER ----------
550
  async function posterPrefill(inputId){
551
  const f=document.getElementById(inputId).files[0]; if(!f) return;
552
- const note=document.getElementById('r_poster_note'); note.textContent='Lecture de l\'affiche…';
 
553
  const fd=new FormData(); fd.append('file',f);
554
  try{
555
  const r=await fetch(API+'/posters',{method:'POST',body:fd}); const d=await r.json();
@@ -557,21 +948,22 @@
557
  const phEl=document.getElementById('r_phone');
558
  if(d.suggested_contact&&phEl) phEl.value=d.suggested_contact;
559
  if(d.suggested_description) document.getElementById('r_desc').value=d.suggested_description;
560
- note.textContent=d.note||'Affiche chargée.';
561
- }catch(e){ note.textContent='Affiche chargée (lecture auto indisponible).'; }
562
  }
563
  function photoHint(){
564
- const n=document.getElementById('r_photos').files.length,h=document.getElementById('r_hint');
565
- if(!n){h.style.color='';h.textContent='Astuce : plusieurs angles et éclairages = bien plus fiable.';return;}
566
- if(n<3){h.style.color='var(--bad)';h.textContent=`${n} photo(s) — ajoutes-en plus (vise 5+) pour bien reconnaître.`;}
567
- else if(n<5){h.style.color='var(--warn)';h.textContent=`${n} photos — bien ! quelques-unes de plus = idéal.`;}
568
- else{h.style.color='var(--ok)';h.textContent=`${n} photos — excellent ✅`;}
 
569
  }
570
  async function doReport(){
571
  const photos=document.getElementById('r_photos').files;
572
- if(!photos.length) return toast('Ajoute au moins une photo du chat');
573
  const email=val('r_email').trim();
574
- if(!email||!email.includes('@')) return toast('Entre ton courriel (pour suivre les signalements)');
575
  const fd=new FormData();
576
  fd.append('name',val('r_name'));
577
  const desc=[val('r_desc'),val('r_details')].filter(Boolean).join(' — ');
@@ -579,44 +971,43 @@
579
  if(pos.r){fd.append('last_seen_lat',pos.r.lat);fd.append('last_seen_lng',pos.r.lng);}
580
  for(const f of photos) fd.append('photos',f);
581
  const prog=progress('r_prog'); prog.show();
 
582
  let r;
583
  try{
584
  r=await postWithProgress(API+'/cats', fd, prog, [
585
- `🧠 ${AI_NAME} crée l'empreinte de ${photos.length>1?'chacune des '+photos.length+' photos':'la photo'}…`,
586
- '💾 Enregistrement sécurisé de la fiche…']);
587
- }catch(e){ return prog.fail('Erreur réseau — réessaie'); }
588
- if(!r.ok){ return prog.fail('Erreur '+r.status+(r.status===429?' — trop de requêtes, patiente une minute':'')); }
589
- prog.done('✅ Fiche enregistrée');
590
  const d=r.json(); localStorage.setItem('chats_email',email);
591
  document.getElementById('r_track_email').value=email;
592
- document.getElementById('r_token').innerHTML=
593
- `<div class="result match" style="margin-top:14px"><b>✅ ${esc(d.name||'Chat')} déclaré (${d.photos_count} photos)</b>
594
- <p class="hint">Pour voir les signalements et le suivi : retourne à l'accueil → « Suivre mon
595
- signalement », entre ton courriel (${email}) et le code qu'on t'enverra. Rien à mémoriser 🙂</p></div>`;
596
  document.getElementById('r_token').scrollIntoView({behavior:'smooth'});
597
  }
598
  async function requestCode(){
599
- const email=val('r_track_email').trim(); if(!email||!email.includes('@')) return toast('Entre ton courriel');
 
600
  const fd=new FormData(); fd.append('email',email);
601
  const r=await fetch(API+'/cats/request-code',{method:'POST',body:fd});
602
  if(!r.ok) return toast('Erreur '+r.status);
603
  const d=await r.json();
604
  document.getElementById('r_code_box').classList.remove('hidden');
605
- if(d.dev_code){ // mode démo : pas de courriel configuré, on affiche le code
606
  document.getElementById('r_track_code').value=d.dev_code;
607
- toast('Démo : code pré-rempli ('+d.dev_code+')');
608
- } else toast(d.message);
609
  }
610
  async function verifyCode(){
611
  const email=val('r_track_email').trim(), code=val('r_track_code').trim();
612
- if(!code) return toast('Entre le code reçu');
613
  const fd=new FormData(); fd.append('email',email); fd.append('code',code);
614
  const r=await fetch(API+'/cats/verify-code',{method:'POST',body:fd});
615
  const box=document.getElementById('r_track');
616
- if(!r.ok){ box.innerHTML='<p class="hint">Code invalide ou expiré. Redemande un code.</p>'; return; }
617
  const acc=await r.json(); ownerCats=acc.cats||[];
618
- if(!acc.cats.length){ box.innerHTML='<p class="hint">Aucun chat rattaché à ce courriel.</p>'; return; }
619
- box.innerHTML='<button class="btn btn-ghost" style="margin-top:0" onclick="followLogout()">🔒 Se déconnecter</button>'
620
  + acc.cats.map(renderOwnerCat).join('');
621
  }
622
  function followLogout(){
@@ -625,65 +1016,64 @@
625
  document.getElementById('r_code_box').classList.add('hidden');
626
  document.getElementById('r_track_email').value='';
627
  localStorage.removeItem('chats_email');
628
- toast('Déconnecté 🔒'); show('home');
629
  }
630
  function renderOwnerCat(c){
 
631
  const leads=c.leads.map(l=>{
632
  const pct=l.confidence?Math.round(l.confidence*100):null;
633
  const img=l.sighting_photo_url?zimg(API+l.sighting_photo_url):'';
634
- const loc=(l.sighting_lat!=null)?`<a target="_blank" rel="noopener noreferrer" href="https://maps.google.com/?q=${Number(l.sighting_lat)},${Number(l.sighting_lng)}">📍 carte</a>`:'';
635
- return `<div class="lead">${img}<div style="flex:1"><b>Signalement</b>${pct?` · ${pct}%`:''}<br>
636
- <span class="hint">${esc(l.message||'(pas de message)')}</span><br>
637
- <span class="hint">📞 ${esc(l.finder_contact||'(pas de contact)')} · ${loc}</span></div></div>`;
638
- }).join('')||'<p class="hint">Aucun signalement pour l\'instant.</p>';
639
  const declared = c.cat.last_seen_at || c.cat.created_at;
640
- const dDate = declared ? new Date(declared).toLocaleDateString('fr-CA',{day:'numeric',month:'long',year:'numeric'}) : '?';
641
  const stats = `<div class="follow-stats">
642
- <div class="fs"><b>${dDate}</b><span>déclaré le</span></div>
643
- <div class="fs"><b>${(c.comparisons??0).toLocaleString('fr-CA')}</b><span>comparaisons IA 🔎</span></div>
644
- <div class="fs"><b>${c.leads.length}</b><span>signalement(s) reçu(s)</span></div>
645
  </div>`;
646
  return `<div style="margin-top:14px;border-top:1px solid var(--line);padding-top:12px">
647
- <b>${esc(c.cat.name||'Mon chat')}</b> — statut : ${esc(c.cat.status)}
648
  ${stats}
649
- <button class="btn btn-ghost" onclick="reunited('${encodeURIComponent(c.claim_token)}')">Marquer « retrouvé » 🎉</button>
650
  <button class="btn btn-ghost" style="color:var(--bad);border-color:var(--bad)"
651
- onclick="deleteCat('${encodeURIComponent(c.claim_token)}')">🗑️ Supprimer définitivement</button>
652
- <h2 style="margin-top:12px">Signalements reçus (${c.leads.length})</h2>${leads}</div>`;
653
  }
654
  async function deleteCat(tok){
655
  const c=ownerCats.find(x=>x.claim_token===tok);
656
- const name=(c&&c.cat&&c.cat.name)?c.cat.name:'cette fiche';
657
- if(!confirm(`Supprimer définitivement la fiche de ${name} ?\nPhotos, signalements et contact seront effacés. Irréversible.`)) return;
658
  const fd=new FormData(); fd.append('token',tok);
659
  const r=await fetch(API+'/cats/track/delete',{method:'POST',body:fd});
660
  if(!r.ok) return toast('Erreur '+r.status);
661
- toast('Fiche supprimée 🗑️');
662
- document.getElementById('r_track').innerHTML='<p class="hint">Fiche supprimée définitivement (droit à l\'oubli).</p>';
663
  }
664
  async function reunited(tok){
665
  const fd=new FormData();fd.append('token',tok);fd.append('status','reunited');
666
  const r=await fetch(API+'/cats/track/status',{method:'POST',body:fd});
667
  if(!r.ok) return toast('Erreur');
668
- const c=await r.json(); toast('Marqué retrouvé 🎉');
669
  document.getElementById('r_track').innerHTML=renderOwnerCat(c);
670
  }
671
 
672
  // ---------- 3. PARTAGER UNE AFFICHE ----------
673
  async function doPoster(){
674
  const f=document.getElementById('p_file').files[0];
675
- if(!f) return toast('Charge une affiche');
676
- // 1) lecture OCR (pré-remplissage / note)
677
  const fd0=new FormData(); fd0.append('file',f);
678
  try{ await fetch(API+'/posters',{method:'POST',body:fd0}); }catch(e){}
679
- // 2) ajout à la banque comme fiche (image seulement)
680
- const fd=new FormData(); fd.append('name','Affiche partagée');
681
  fd.append('description',val('p_text'));
682
  if(pos.p){fd.append('last_seen_lat',pos.p.lat);fd.append('last_seen_lng',pos.p.lng);}
683
  if(f.type.startsWith('image/')) fd.append('photos',f);
684
  const r=await fetch(API+'/cats',{method:'POST',body:fd});
685
  const box=document.getElementById('p_result');
686
- if(r.ok){ box.innerHTML='<div class="result match"><div class="big">🙏</div><b>Merci !</b><p class="hint">Affiche ajoutée à la banque.</p></div>';
687
  document.getElementById('p_file').value=''; document.getElementById('p_prev').innerHTML=''; document.getElementById('p_text').value='';
688
  } else box.innerHTML='<p class="hint">Erreur '+r.status+'.</p>';
689
  }
@@ -691,38 +1081,40 @@
691
  // ---------- 4. ENTRAÎNER ----------
692
  async function doContribute(){
693
  const photos=document.getElementById('t_photos').files;
694
- if(!photos.length) return toast('Ajoute des photos');
695
- if(!document.getElementById('t_consent').checked) return toast('Coche la case de consentement d\'abord 🙏');
696
  const fd=new FormData(); fd.append('consent','1');
697
  for(const f of photos) fd.append('photos',f);
698
- toast('Envoi…');
699
  const r=await fetch(API+'/training/photos',{method:'POST',body:fd});
700
  if(!r.ok) return toast('Erreur '+r.status);
701
- const d=await r.json(); toast(`Merci ! ${d.photos} photos dans le pool 🙏`);
702
  document.getElementById('t_photos').value=''; document.getElementById('t_prev').innerHTML=''; loadTrainStats();
703
  }
704
  async function nextPair(){
705
- const g=document.getElementById('game'); g.innerHTML='<p class="hint">Chargement…</p>';
706
  const r=await fetch(API+'/training/pair');
707
- if(!r.ok){ g.innerHTML='<p class="hint">Pas encore assez de photos données. Ajoute-en d\'abord ci-dessus 🙂</p>'; return; }
708
  gamePair=await r.json();
709
  g.innerHTML=`<div class="game-pair"><img src="${encodeURI(API+gamePair.a.url)}" onclick="zoom(this.src)"><img src="${encodeURI(API+gamePair.b.url)}" onclick="zoom(this.src)"></div>
710
  <div class="seg" style="margin-top:12px">
711
- <button onclick="sendLabel('same')">😺 Même chat</button>
712
- <button onclick="sendLabel('different')">🙀 Différents</button>
713
  </div>`;
714
  }
715
  async function sendLabel(v){
716
  if(!gamePair) return;
717
  await fetch(API+'/training/label',{method:'POST',headers:{'Content-Type':'application/json'},
718
  body:JSON.stringify({photo_a_id:gamePair.a.id,photo_b_id:gamePair.b.id,verdict:v})});
719
- toast('Merci ! 🎓'); loadTrainStats(); nextPair();
720
  }
721
  async function loadTrainStats(){
722
  try{const d=await (await fetch(API+'/training/stats')).json();
723
- document.getElementById('t_stats').textContent=`Pool : ${d.photos} photos · ${d.labels} réponses données`;}catch(e){}
724
  }
725
 
 
 
726
  const savedEmail=localStorage.getItem('chats_email'); if(savedEmail) document.getElementById('r_track_email').value=savedEmail;
727
  </script>
728
  </body>
 
17
  padding:14px 16px;display:flex;align-items:center;gap:10px;box-shadow:var(--shadow)}
18
  header .back{background:rgba(255,255,255,.2);border:none;color:#fff;font-size:20px;width:40px;height:40px;
19
  border-radius:10px;display:none}
20
+ header h1{font-size:19px;margin:0;font-weight:800;letter-spacing:.2px;flex:1}
21
+ .lang-toggle{display:flex;gap:5px;flex-shrink:0}
22
+ .lang-btn{background:rgba(255,255,255,.18);border:none;color:rgba(255,255,255,.8);font-size:12px;
23
+ font-weight:800;padding:5px 10px;border-radius:8px;cursor:pointer;letter-spacing:.4px}
24
+ .lang-btn.on{background:rgba(255,255,255,.45);color:#fff}
25
  main{max-width:680px;margin:0 auto;padding:16px}
26
  .sub{color:var(--muted);font-size:15px;margin:2px 0 18px}
27
  /* Home action cards */
 
102
  #toast{position:fixed;bottom:22px;left:50%;transform:translateX(-50%);background:#16202c;color:#fff;
103
  padding:12px 18px;border-radius:24px;opacity:0;transition:.3s;z-index:99;font-weight:600;max-width:90%}
104
  #toast.show{opacity:1}
105
+ /* Visionneuse plein écran */
106
  .candidate img,.lead img,.game-pair img{cursor:zoom-in}
 
107
  .zwrap{position:relative;display:inline-block;flex:none;line-height:0}
108
  .zwrap .zbadge{position:absolute;right:4px;bottom:4px;background:rgba(8,12,18,.66);color:#fff;
109
  border-radius:9px;font-size:12px;padding:1px 6px;pointer-events:none;line-height:1.5}
 
118
  footer{max-width:680px;margin:8px auto 28px;padding:18px 16px 0;border-top:1px solid var(--line);
119
  text-align:center;color:var(--muted);font-size:12.5px;line-height:1.7}
120
  footer a{color:var(--muted);text-decoration:underline;cursor:pointer}
121
+ /* Page légale */
122
  .legal p{font-size:14.5px;color:#2b3a48;margin:10px 0}
123
  .legal ul{font-size:14.5px;color:#2b3a48;padding-left:18px;margin:8px 0}
124
  .legal li{margin:7px 0}
125
  .legal a{color:var(--accent2)}
126
+ /* FAQ */
127
+ .faq{margin-top:168px;margin-bottom:90px}
128
  .faq h3{font-size:12px;font-weight:700;color:var(--muted);letter-spacing:.6px;text-transform:uppercase;margin:0 0 2px}
129
  .faq details{border-bottom:1px solid var(--line)}
130
  .faq summary{font-weight:600;cursor:pointer;font-size:14px;color:var(--ink);list-style:none;display:flex;gap:9px;padding:12px 2px}
 
139
  <header>
140
  <button class="back" id="backBtn" onclick="goHome()">‹</button>
141
  <h1 id="hTitle">🐱 Chats perdus (version Beta)</h1>
142
+ <div class="lang-toggle">
143
+ <button class="lang-btn on" id="btn_lang_fr" onclick="setLang('fr')">FR</button>
144
+ <button class="lang-btn" id="btn_lang_en" onclick="setLang('en')">EN</button>
145
+ </div>
146
  </header>
147
  <main>
148
 
149
  <!-- ===================== ACCUEIL ===================== -->
150
  <section id="home">
151
+ <p class="sub" data-i18n="home_sub">Entraide communautaire pour retrouver nos compagnons.</p>
152
 
 
153
  <div class="home">
154
  <button class="action hero hero-lost" onclick="show('report')">
155
  <div class="ico">🚨</div><div>
156
+ <span class="tag tag-lost" data-i18n="tag_owner">Propriétaire</span>
157
+ <div class="t" data-i18n="hero_lost_title">J'ai perdu mon chat</div>
158
+ <div class="d" data-i18n-html="hero_lost_desc">Déclare sa disparition en envoyant quelques photos (et son affiche, si tu en as une). Si un passant le croise et le prend en photo, notre IA le reconnaîtra.<br><br>
159
  • Alerte immédiate : Tu reçois un courriel dès qu'il est repéré.<br>
160
  • Vie privée protégée : Tu es le seul à décider de contacter le trouveur. Ton courriel et tes coordonnées ne sont jamais divulgués.</div></div>
161
  </button>
162
  <button class="action hero hero-seen" onclick="show('check')">
163
  <div class="ico">📷</div><div>
164
+ <span class="tag tag-seen" data-i18n="tag_finder">Passant / Trouveur</span>
165
+ <div class="t" data-i18n="hero_seen_title">J'ai vu un chat</div>
166
+ <div class="d" data-i18n-html="hero_seen_desc">Prends une photo du chat croisé qui semble perdu. L'IA compare instantanément sa ressemblance avec notre base de données et te montre les profils similaires.<br><br>
167
  • Signalement rapide : Si tu reconnais le chat, valide la correspondance pour envoyer une notification automatique et anonyme à sa famille.</div></div>
168
  </button>
169
  <button class="action hero hero-follow" onclick="show('follow')">
170
  <div class="ico">ℹ️</div><div>
171
+ <span class="tag tag-follow" data-i18n="tag_follow">Suivi</span>
172
+ <div class="t" data-i18n="hero_follow_title">Suivre mon signalement</div>
173
+ <div class="d" data-i18n="hero_follow_desc">Accède à ton espace pour gérer ton alerte ou clôturer la recherche.</div></div>
174
  </button>
175
  </div>
176
 
177
+ <p class="sub" style="margin-top:24px;margin-bottom:8px" data-i18n="community_sub">Aider la communauté :</p>
 
178
  <div class="secondary">
179
  <button class="action mini" onclick="show('poster')">
180
+ <div class="ico">📋</div><div><div class="t" data-i18n="mini_poster_title">Partager une affiche vue</div>
181
+ <div class="d" data-i18n="mini_poster_desc">Enrichir la banque</div></div></button>
182
  <button class="action mini" onclick="show('train')">
183
+ <div class="ico">🧠</div><div><div class="t" data-i18n="mini_train_title">Aider l'IA</div>
184
+ <div class="d" data-i18n="mini_train_desc">Photos + jeu de reconnaissance</div></div></button>
185
  </div>
186
 
 
187
  <div class="faq">
188
+ <h3 data-i18n="faq_title">Questions fréquentes</h3>
189
+ <details><summary data-i18n="faq_q1">Comment ça fonctionne&nbsp;?</summary>
190
+ <p data-i18n="faq_a1">Tu déclares ton chat perdu avec ses photos. Quand quelqu'un croise un chat, il en
191
  prend une photo : l'IA la compare aux chats perdus et montre les ressemblances. Si la
192
  personne reconnaît ton chat, tu reçois un signalement par courriel.</p></details>
193
+ <details><summary data-i18n="faq_q2">Mes informations restent-elles privées&nbsp;?</summary>
194
+ <p data-i18n="faq_a2">Oui. Aucun annuaire public : la personne qui repère un chat ne voit jamais tes
195
  coordonnées. On te rejoint par un code envoyé à ton courriel, et tu peux tout
196
  supprimer quand tu veux.</p></details>
197
+ <details><summary data-i18n="faq_q3">Est-ce gratuit&nbsp;?</summary>
198
+ <p data-i18n="faq_a3">Oui, entièrement. C'est un projet d'entraide communautaire, sans publicité.</p></details>
199
+ <details><summary data-i18n="faq_q4">Quelle technologie derrière&nbsp;?</summary>
200
+ <p data-i18n-html="faq_a4">Allo Chat utilise <a href="https://huggingface.co/conservationxlabs/miewid-msv3" target="_blank" rel="noopener noreferrer">MiewID</a>, un modèle d'IA de ré-identification animale qui transforme
201
  chaque photo en une « empreinte » visuelle. On compare ces empreintes pour repérer les
202
  chats qui se ressemblent, puis un humain confirme toujours la correspondance.</p></details>
203
  </div>
 
205
 
206
  <!-- ===================== 1. VÉRIFIER ===================== -->
207
  <section id="check" class="hidden">
208
+ <div class="privacy" data-i18n="check_privacy">🔒 On ne montre que les chats qui ressemblent à ta photo. Le contact du
209
  propriétaire reste privé — c'est lui qui te recontacte.</div>
210
  <div class="card">
211
+ <h2 data-i18n="check_h2">Tu as croisé un chat&nbsp;?</h2>
212
+ <label data-i18n="check_photo_label">Photo du chat</label>
213
+ <label class="photo-btn" for="c_photo" data-i18n="check_photo_btn">📷 Prendre / choisir une photo</label>
214
  <input class="file-hidden" id="c_photo" type="file" accept="image/*" onchange="prev('c_photo','c_prev')" />
215
  <div class="thumbs" id="c_prev"></div>
216
+ <div class="hint" id="c_gps" data-i18n="check_gps">📍 Position : non captée (optionnel, aide au tri)</div>
217
+ <button class="btn btn-ghost" onclick="getPos('c')" data-i18n="getpos_btn">Capter ma position</button>
218
+ <button class="btn btn-go" onclick="doCheck()" data-i18n="check_btn">🔍 Vérifier</button>
219
  <div class="search-prog" id="c_prog"><div class="bar"><div id="c_prog_fill"></div></div>
220
  <div class="lbl"><span class="dot"></span><span id="c_prog_txt"></span></div></div>
221
  </div>
222
  <div id="c_result"></div>
223
  <div id="c_leadbox" class="card hidden">
224
+ <h2 id="c_leadtitle" data-i18n="lead_title">Prévenir le propriétaire 🐾</h2>
225
+ <p class="hint" data-i18n="lead_hint">Laisse TES coordonnées : le propriétaire te recontactera.</p>
226
+ <label data-i18n="lead_contact_label">Ton contact (téléphone / courriel)</label>
227
  <input id="c_contact" placeholder="514-555-1234" />
228
+ <label data-i18n="lead_msg_label">Message (où, quand, état du chat)</label>
229
+ <textarea id="c_msg" rows="2" data-i18n-placeholder="lead_msg_placeholder" placeholder="Vu ce matin près du parc"></textarea>
230
+ <button class="btn btn-primary" onclick="sendLead()" data-i18n="lead_send_btn">Envoyer au propriétaire</button>
231
  </div>
232
  </section>
233
 
234
  <!-- ===================== 2. SIGNALER ===================== -->
235
  <section id="report" class="hidden">
236
+ <div class="privacy" data-i18n-html="report_privacy">🔒 Ton contact reste privé. Tu reçois un <b>code secret</b> pour suivre les
237
  signalements. Plus tu mets de photos, mieux l'IA reconnaît ton chat.</div>
238
  <div class="card">
239
+ <h2 data-i18n="report_h2">Signaler mon chat perdu</h2>
240
+ <label data-i18n="report_poster_label">Tu as une affiche&nbsp;? (photo ou PDF — optionnel)</label>
241
+ <label class="photo-btn" for="r_poster" data-i18n="report_poster_btn">🖼️ Charger l'affiche (remplit le formulaire)</label>
242
  <input class="file-hidden" id="r_poster" type="file" accept="image/*,application/pdf" onchange="posterPrefill('r_poster')" />
243
  <div class="hint" id="r_poster_note"></div>
244
 
245
+ <label data-i18n="report_name_label">Nom du chat</label>
246
+ <input id="r_name" data-i18n-placeholder="report_name_placeholder" placeholder="Tigrou" />
247
+ <label data-i18n="report_desc_label">Description (robe, signes distinctifs)</label>
248
+ <textarea id="r_desc" rows="2" data-i18n-placeholder="report_desc_placeholder" placeholder="Tortie, médaillon blanc, timide"></textarea>
249
+ <label data-i18n="report_email_label">Ton courriel (sert à suivre les signalements — jamais montré aux autres)</label>
250
+ <input id="r_email" type="email" data-i18n-placeholder="report_email_placeholder" placeholder="toi@exemple.com" />
251
+ <label data-i18n="report_phone_label">Ton téléphone (optionnel, privé)</label>
252
  <input id="r_phone" type="tel" placeholder="514-555-1234" />
253
+ <label data-i18n="report_details_label">Détails supplémentaires (optionnel)</label>
254
+ <textarea id="r_details" rows="2" data-i18n-placeholder="report_details_placeholder" placeholder="Disparu le 3 juin, secteur Rosemont, collier bleu…"></textarea>
255
 
256
+ <label data-i18n-html="report_photos_label">Photos du chat — <b>5 à 10 recommandé</b> 🎯</label>
257
+ <label class="photo-btn" for="r_photos" data-i18n="report_photos_btn">📷 Ajouter des photos du chat</label>
258
  <input class="file-hidden" id="r_photos" type="file" accept="image/*" multiple onchange="prev('r_photos','r_prev'); photoHint()" />
259
  <div class="thumbs" id="r_prev"></div>
260
+ <div class="hint" id="r_hint" data-i18n="report_photo_hint">Astuce : plusieurs angles et éclairages = bien plus fiable.</div>
261
+ <div class="hint" id="r_gps" data-i18n="report_gps">📍 Dernière position connue : non captée</div>
262
+ <button class="btn btn-ghost" onclick="getPos('r')" data-i18n="report_getpos_btn">Capter la position</button>
263
+ <button class="btn btn-primary" onclick="doReport()" data-i18n="report_btn">🚨 Déclarer (obtenir mon code)</button>
264
  <div class="search-prog" id="r_prog"><div class="bar"><div id="r_prog_fill"></div></div>
265
  <div class="lbl"><span class="dot"></span><span id="r_prog_txt"></span></div></div>
266
  <div id="r_token"></div>
 
269
 
270
  <!-- ===================== SUIVRE UN SIGNALEMENT ===================== -->
271
  <section id="follow" class="hidden">
272
+ <div class="privacy" data-i18n="follow_privacy">🔒 Accès sécurisé : on t'envoie un code par courriel pour vérifier
273
  que c'est bien toi. Tu vois alors quand tu as déclaré ton chat, combien de fois l'IA
274
  l'a comparé, et les signalements reçus.</div>
275
  <div class="card">
276
+ <h2 data-i18n="follow_h2">Suivre mon signalement</h2>
277
+ <label data-i18n="follow_email_label">Ton courriel (celui de la déclaration)</label>
278
+ <input id="r_track_email" type="email" data-i18n-placeholder="follow_email_placeholder" placeholder="toi@exemple.com" />
279
+ <button class="btn btn-ghost" onclick="requestCode()" data-i18n="follow_request_btn">📩 Recevoir mon code</button>
280
  <div id="r_code_box" class="hidden">
281
+ <label data-i18n="follow_code_label">Code reçu</label>
282
  <input id="r_track_code" inputmode="numeric" placeholder="123456" />
283
+ <button class="btn btn-go" onclick="verifyCode()" data-i18n="follow_verify_btn">Voir mon suivi</button>
284
  </div>
285
  <div id="r_track"></div>
286
  </div>
 
288
 
289
  <!-- ===================== 3. PARTAGER UNE AFFICHE ===================== -->
290
  <section id="poster" class="hidden">
291
+ <div class="privacy" data-i18n="poster_privacy">📋 Les affiches sont publiques. En les partageant, tu enrichis la banque et
292
  aides d'autres personnes à retrouver leur chat.</div>
293
  <div class="card">
294
+ <h2 data-i18n="poster_h2">Partager une affiche trouvée</h2>
295
+ <label data-i18n="poster_file_label">Photo ou PDF de l'affiche</label>
296
+ <label class="photo-btn" for="p_file" data-i18n="poster_file_btn">📷 Prendre / charger l'affiche</label>
297
  <input class="file-hidden" id="p_file" type="file" accept="image/*,application/pdf" onchange="prev('p_file','p_prev')" />
298
  <div class="thumbs" id="p_prev"></div>
299
+ <label data-i18n="poster_text_label">Infos visibles sur l'affiche (optionnel)</label>
300
+ <textarea id="p_text" rows="3" data-i18n-placeholder="poster_text_placeholder" placeholder="Nom, secteur, description… (ce qui est écrit sur l'affiche)"></textarea>
301
+ <div class="hint" id="p_gps" data-i18n="poster_gps">📍 Où l'as-tu vue ? (optionnel)</div>
302
+ <button class="btn btn-ghost" onclick="getPos('p')" data-i18n="getpos_btn">Capter ma position</button>
303
+ <button class="btn btn-go" onclick="doPoster()" data-i18n="poster_btn">📋 Ajouter à la banque</button>
304
  <div id="p_result"></div>
305
  </div>
306
  </section>
307
 
308
  <!-- ===================== 4. ENTRAÎNER L'IA ===================== -->
309
  <section id="train" class="hidden">
310
+ <div class="privacy" data-i18n="train_privacy">🧠 Aide volontaire. Ces photos servent UNIQUEMENT à entraîner l'IA — elles
311
  ne sont jamais mêlées aux fiches privées de chats perdus.</div>
312
  <div class="card">
313
+ <h2 data-i18n="train_photos_h2">Donner des photos</h2>
314
+ <p class="hint" data-i18n="train_photos_hint">Ajoute des photos de chats (le tien, ou des chats croisés) pour aider l'IA à mieux reconnaître.</p>
315
+ <label class="photo-btn" for="t_photos" data-i18n="train_photos_btn">📷 Ajouter des photos</label>
316
  <input class="file-hidden" id="t_photos" type="file" accept="image/*" multiple onchange="prev('t_photos','t_prev')" />
317
  <div class="thumbs" id="t_prev"></div>
318
  <label style="display:flex;gap:10px;align-items:flex-start;font-weight:400;font-size:13.5px;margin-top:12px">
319
  <input id="t_consent" type="checkbox" style="width:auto;margin-top:2px" />
320
+ <span data-i18n-html="train_consent">J'accepte que ces images soient utilisées pour <b>entraîner le modèle de
321
  reconnaissance</b>. Elles ne seront pas mêlées aux fiches de chats perdus.</span>
322
  </label>
323
+ <button class="btn btn-go" onclick="doContribute()" data-i18n="train_photos_submit">Donner ces photos</button>
324
  </div>
325
  <div class="card">
326
+ <h2 data-i18n="train_game_h2">Jouer : même chat ou pas&nbsp;?</h2>
327
+ <p class="hint" data-i18n="train_game_hint">Regarde deux photos et dis si c'est le même chat. Chaque réponse aide l'IA.</p>
328
+ <div id="game"><button class="btn btn-go" onclick="nextPair()" data-i18n="train_game_start">Commencer à jouer</button></div>
329
  <div class="hint" id="t_stats"></div>
330
  </div>
331
  </section>
332
 
333
  <!-- ===================== AVIS LÉGAL & CONFIDENTIALITÉ ===================== -->
334
  <section id="legal" class="hidden">
335
+ <div class="card legal" id="legal_content">
336
  <h2>Avis de non-responsabilité</h2>
337
  <p>Allo Chat est un service communautaire <b>gratuit</b> d'entraide pour aider à
338
  retrouver des chats perdus, à Montréal et ailleurs.</p>
 
350
  <p><b>Conduite des utilisateurs.</b> Vous êtes responsable des renseignements que vous
351
  publiez et de vos échanges. Faites preuve de prudence lors d'un contact avec une
352
  personne inconnue, et ne tentez pas d'approcher un animal craintif ou en détresse.</p>
 
353
  <h2 style="margin-top:22px">Confidentialité — Loi 25 (Québec)</h2>
354
  <p>Allo Chat respecte la <i>Loi sur la protection des renseignements personnels dans le
355
  secteur privé</i> (Loi 25). Voici l'essentiel.</p>
 
389
 
390
  </main>
391
 
392
+ <footer id="footer_el">
393
  🐾 Fait à Montréal · © 2026 Allo Chat ·
394
  <a onclick="show('legal')">Avis légal &amp; confidentialité</a><br>
395
  Contact : <a href="mailto:info@allochat.org">info@allochat.org</a>
 
399
  <div id="lightbox" onclick="closeZoom(event)">
400
  <button class="x" onclick="closeZoom(event)" aria-label="Fermer">✕</button>
401
  <img id="lightbox_img" alt="Photo du chat en grand">
402
+ <div class="hint-zoom" data-i18n="lightbox_hint">Touche l'image ou appuie sur ✕ pour fermer</div>
403
  </div>
404
 
405
  <script>
406
  const API = location.origin;
407
  const pos = {c:null,r:null,p:null};
408
  let lastSightingId=null, chosenCandidateToken=null, gamePair=null;
409
+ let lastMatches=[], ownerCats=[];
410
 
 
 
411
  const esc=s=>String(s==null?'':s).replace(/[&<>"']/g,c=>(
412
  {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
413
 
414
+ // ---- TRADUCTIONS / TRANSLATIONS ----
415
+ const T = {
416
+ fr: {
417
+ home_sub: 'Entraide communautaire pour retrouver nos compagnons.',
418
+ tag_owner: 'Propriétaire',
419
+ hero_lost_title: "J'ai perdu mon chat",
420
+ hero_lost_desc: "Déclare sa disparition en envoyant quelques photos (et son affiche, si tu en as une). Si un passant le croise et le prend en photo, notre IA le reconnaîtra.<br><br>• Alerte immédiate : Tu reçois un courriel dès qu'il est repéré.<br>• Vie privée protégée : Tu es le seul à décider de contacter le trouveur. Ton courriel et tes coordonnées ne sont jamais divulgués.",
421
+ tag_finder: 'Passant / Trouveur',
422
+ hero_seen_title: "J'ai vu un chat",
423
+ hero_seen_desc: "Prends une photo du chat croisé qui semble perdu. L'IA compare instantanément sa ressemblance avec notre base de données et te montre les profils similaires.<br><br>• Signalement rapide : Si tu reconnais le chat, valide la correspondance pour envoyer une notification automatique et anonyme à sa famille.",
424
+ tag_follow: 'Suivi',
425
+ hero_follow_title: 'Suivre mon signalement',
426
+ hero_follow_desc: 'Accède à ton espace pour gérer ton alerte ou clôturer la recherche.',
427
+ community_sub: 'Aider la communauté :',
428
+ mini_poster_title: 'Partager une affiche vue',
429
+ mini_poster_desc: 'Enrichir la banque',
430
+ mini_train_title: "Aider l'IA",
431
+ mini_train_desc: 'Photos + jeu de reconnaissance',
432
+ faq_title: 'Questions fréquentes',
433
+ faq_q1: 'Comment ça fonctionne\u00a0?',
434
+ faq_a1: "Tu déclares ton chat perdu avec ses photos. Quand quelqu'un croise un chat, il en prend une photo : l'IA la compare aux chats perdus et montre les ressemblances. Si la personne reconnaît ton chat, tu reçois un signalement par courriel.",
435
+ faq_q2: 'Mes informations restent-elles privées\u00a0?',
436
+ faq_a2: "Oui. Aucun annuaire public : la personne qui repère un chat ne voit jamais tes coordonnées. On te rejoint par un code envoyé à ton courriel, et tu peux tout supprimer quand tu veux.",
437
+ faq_q3: 'Est-ce gratuit\u00a0?',
438
+ faq_a3: "Oui, entièrement. C'est un projet d'entraide communautaire, sans publicité.",
439
+ faq_q4: 'Quelle technologie derrière\u00a0?',
440
+ faq_a4: 'Allo Chat utilise <a href="https://huggingface.co/conservationxlabs/miewid-msv3" target="_blank" rel="noopener noreferrer">MiewID</a>, un modèle d\'IA de ré-identification animale qui transforme chaque photo en une « empreinte » visuelle. On compare ces empreintes pour repérer les chats qui se ressemblent, puis un humain confirme toujours la correspondance.',
441
+ title_home: '🐱 Chats perdus (version Beta)',
442
+ title_check: "📷 J'ai vu un chat",
443
+ title_report: "🚨 J'ai perdu mon chat",
444
+ title_follow: 'ℹ️ Suivre mon signalement',
445
+ title_poster: '📋 Partager une affiche',
446
+ title_train: "🧠 Aider l'IA",
447
+ title_legal: '⚖️ Avis légal & confidentialité',
448
+ check_privacy: "🔒 On ne montre que les chats qui ressemblent à ta photo. Le contact du propriétaire reste privé — c'est lui qui te recontacte.",
449
+ check_h2: 'Tu as croisé un chat\u00a0?',
450
+ check_photo_label: 'Photo du chat',
451
+ check_photo_btn: '📷 Prendre / choisir une photo',
452
+ check_gps: '📍 Position : non captée (optionnel, aide au tri)',
453
+ getpos_btn: 'Capter ma position',
454
+ check_btn: '🔍 Vérifier',
455
+ lead_title: 'Prévenir le propriétaire 🐾',
456
+ lead_hint: 'Laisse TES coordonnées : le propriétaire te recontactera.',
457
+ lead_contact_label: 'Ton contact (téléphone / courriel)',
458
+ lead_msg_label: 'Message (où, quand, état du chat)',
459
+ lead_msg_placeholder: 'Vu ce matin près du parc',
460
+ lead_send_btn: 'Envoyer au propriétaire',
461
+ report_privacy: "🔒 Ton contact reste privé. Tu reçois un <b>code secret</b> pour suivre les signalements. Plus tu mets de photos, mieux l'IA reconnaît ton chat.",
462
+ report_h2: 'Signaler mon chat perdu',
463
+ report_poster_label: 'Tu as une affiche\u00a0? (photo ou PDF — optionnel)',
464
+ report_poster_btn: "🖼️ Charger l'affiche (remplit le formulaire)",
465
+ report_name_label: 'Nom du chat',
466
+ report_name_placeholder: 'Tigrou',
467
+ report_desc_label: 'Description (robe, signes distinctifs)',
468
+ report_desc_placeholder: 'Tortie, médaillon blanc, timide',
469
+ report_email_label: 'Ton courriel (sert à suivre les signalements — jamais montré aux autres)',
470
+ report_email_placeholder: 'toi@exemple.com',
471
+ report_phone_label: 'Ton téléphone (optionnel, privé)',
472
+ report_details_label: 'Détails supplémentaires (optionnel)',
473
+ report_details_placeholder: 'Disparu le 3 juin, secteur Rosemont, collier bleu…',
474
+ report_photos_label: 'Photos du chat — <b>5 à 10 recommandé</b> 🎯',
475
+ report_photos_btn: '📷 Ajouter des photos du chat',
476
+ report_photo_hint: 'Astuce : plusieurs angles et éclairages = bien plus fiable.',
477
+ report_gps: '📍 Dernière position connue : non captée',
478
+ report_getpos_btn: 'Capter la position',
479
+ report_btn: '🚨 Déclarer (obtenir mon code)',
480
+ follow_privacy: "🔒 Accès sécurisé : on t'envoie un code par courriel pour vérifier que c'est bien toi. Tu vois alors quand tu as déclaré ton chat, combien de fois l'IA l'a comparé, et les signalements reçus.",
481
+ follow_h2: 'Suivre mon signalement',
482
+ follow_email_label: 'Ton courriel (celui de la déclaration)',
483
+ follow_email_placeholder: 'toi@exemple.com',
484
+ follow_request_btn: '📩 Recevoir mon code',
485
+ follow_code_label: 'Code reçu',
486
+ follow_verify_btn: 'Voir mon suivi',
487
+ poster_privacy: "📋 Les affiches sont publiques. En les partageant, tu enrichis la banque et aides d'autres personnes à retrouver leur chat.",
488
+ poster_h2: 'Partager une affiche trouvée',
489
+ poster_file_label: "Photo ou PDF de l'affiche",
490
+ poster_file_btn: "📷 Prendre / charger l'affiche",
491
+ poster_text_label: "Infos visibles sur l'affiche (optionnel)",
492
+ poster_text_placeholder: "Nom, secteur, description… (ce qui est écrit sur l'affiche)",
493
+ poster_gps: "📍 Où l'as-tu vue ? (optionnel)",
494
+ poster_btn: '📋 Ajouter à la banque',
495
+ train_privacy: "🧠 Aide volontaire. Ces photos servent UNIQUEMENT à entraîner l'IA — elles ne sont jamais mêlées aux fiches privées de chats perdus.",
496
+ train_photos_h2: 'Donner des photos',
497
+ train_photos_hint: "Ajoute des photos de chats (le tien, ou des chats croisés) pour aider l'IA à mieux reconnaître.",
498
+ train_photos_btn: '📷 Ajouter des photos',
499
+ train_consent: "J'accepte que ces images soient utilisées pour <b>entraîner le modèle de reconnaissance</b>. Elles ne seront pas mêlées aux fiches de chats perdus.",
500
+ train_photos_submit: 'Donner ces photos',
501
+ train_game_h2: 'Jouer : même chat ou pas\u00a0?',
502
+ train_game_hint: "Regarde deux photos et dis si c'est le même chat. Chaque réponse aide l'IA.",
503
+ train_game_start: 'Commencer à jouer',
504
+ lightbox_hint: 'Touche l\'image ou appuie sur ✕ pour fermer',
505
+ footer_html: "🐾 Fait à Montréal · © 2026 Allo Chat · <a onclick=\"show('legal')\">Avis légal &amp; confidentialité</a><br>Contact : <a href=\"mailto:info@allochat.org\">info@allochat.org</a>",
506
+ legal_html: `<h2>Avis de non-responsabilité</h2>
507
+ <p>Allo Chat est un service communautaire <b>gratuit</b> d'entraide pour aider à retrouver des chats perdus, à Montréal et ailleurs.</p>
508
+ <p><b>Service fourni « tel quel », sans garantie.</b> Les correspondances proposées par l'IA sont <b>seulement indicatives</b> et doivent toujours être confirmées par un humain. Allo Chat ne garantit en aucun cas qu'un chat sera retrouvé. <b>L'utilisation se fait à vos propres risques.</b></p>
509
+ <p><b>Limitation de responsabilité.</b> L'exploitant d'Allo Chat ne pourra être tenu responsable de tout dommage direct ou indirect résultant de l'utilisation du service, des interactions entre utilisateurs, d'une fausse correspondance ou d'une interruption du service.</p>
510
+ <p><b>Conduite des utilisateurs.</b> Vous êtes responsable des renseignements que vous publiez. Faites preuve de prudence lors d'un contact avec une personne inconnue.</p>
511
+ <h2 style="margin-top:22px">Confidentialité — Loi 25 (Québec)</h2>
512
+ <ul>
513
+ <li><b>Responsable :</b> Équipe Allo Chat — <a href="mailto:info@allochat.org">info@allochat.org</a>.</li>
514
+ <li><b>Données recueillies :</b> photos de chats, courriel, téléphone (facultatif), description, position (facultative).</li>
515
+ <li><b>Finalité unique :</b> mettre en relation propriétaires et trouveurs. <b>Aucune revente, aucune publicité, aucun profilage.</b></li>
516
+ <li><b>Confidentialité par défaut :</b> aucun annuaire public. Le trouveur ne voit jamais vos coordonnées — accès par code de vérification par courriel.</li>
517
+ <li><b>Droit à l'oubli :</b> suppression définitive de votre fiche à tout moment via « Suivre mon signalement ».</li>
518
+ <li><b>Hébergement :</b> serveurs aux États-Unis, protégés (accès restreint, liens signés, chiffrement en transit).</li>
519
+ <li><b>Droits :</b> accès, rectification, suppression → <a href="mailto:info@allochat.org">info@allochat.org</a>.</li>
520
+ </ul>
521
+ <p class="hint" style="margin-top:14px">Dernière mise à jour : juin 2026.</p>`,
522
+ // JS dynamic strings
523
+ js_add_photo: 'Ajoute une photo du chat',
524
+ js_net_err: 'Erreur réseau — réessaie',
525
+ js_rate: ' — trop de requêtes, patiente une minute',
526
+ js_analysis_done: '✅ Analyse terminée',
527
+ js_no_match: 'Aucune correspondance',
528
+ js_match_q: 'Reconnais-tu l\'un d\'eux\u00a0?',
529
+ js_match_count: n => `${n} chat(s) qui ressemble(nt)`,
530
+ js_conf_strong: 'Forte', js_conf_medium: 'Moyenne', js_conf_weak: 'Faible',
531
+ js_its_him: "C'est lui ✅",
532
+ js_view_poster: '🖼️ Voir l\'affiche',
533
+ js_no_poster_photo: 'Aucune photo d\'affiche disponible.',
534
+ js_cat_default: 'Chat perdu',
535
+ js_choose_cat_prompt: 'Choisis un chat',
536
+ js_lead_sent: '<div class="result match"><div class="big">🙏</div><b>Merci !</b><p class="hint">Le propriétaire a reçu ton signalement.</p></div>',
537
+ js_email_required: 'Entre ton courriel (pour suivre les signalements)',
538
+ js_email_short: 'Entre ton courriel',
539
+ js_code_invalid: '<p class="hint">Code invalide ou expiré. Redemande un code.</p>',
540
+ js_no_cats: '<p class="hint">Aucun chat rattaché à ce courriel.</p>',
541
+ js_logout: 'Déconnecté 🔒',
542
+ js_logout_btn: '🔒 Se déconnecter',
543
+ js_report_label: 'Signalement',
544
+ js_no_message: '(pas de message)',
545
+ js_no_contact: '(pas de contact)',
546
+ js_no_leads: 'Aucun signalement pour l\'instant.',
547
+ js_declared_on: 'déclaré le',
548
+ js_ai_compare: 'comparaisons IA 🔎',
549
+ js_leads_received: 'signalement(s) reçu(s)',
550
+ js_my_cat: 'Mon chat',
551
+ js_status: 'statut',
552
+ js_mark_found: 'Marquer « retrouvé » 🎉',
553
+ js_delete: '🗑️ Supprimer définitivement',
554
+ js_leads_title: n => `Signalements reçus (${n})`,
555
+ js_map: '📍 carte',
556
+ js_delete_confirm: (name) => `Supprimer définitivement la fiche de ${name} ?\nPhotos, signalements et contact seront effacés. Irréversible.`,
557
+ js_this_profile: 'cette fiche',
558
+ js_deleted_toast: 'Fiche supprimée 🗑️',
559
+ js_deleted_html: '<p class="hint">Fiche supprimée définitivement (droit à l\'oubli).</p>',
560
+ js_found_toast: 'Marqué retrouvé 🎉',
561
+ js_add_poster: 'Charge une affiche',
562
+ js_poster_added: '<div class="result match"><div class="big">🙏</div><b>Merci !</b><p class="hint">Affiche ajoutée à la banque.</p></div>',
563
+ js_add_photos: 'Ajoute des photos',
564
+ js_consent_required: 'Coche la case de consentement d\'abord 🙏',
565
+ js_sending: 'Envoi…',
566
+ js_photos_donated: photos => `Merci ! ${photos} photos dans le pool 🙏`,
567
+ js_loading: 'Chargement…',
568
+ js_not_enough_photos: 'Pas encore assez de photos données. Ajoute-en d\'abord ci-dessus 🙂',
569
+ js_same_cat: '😺 Même chat',
570
+ js_diff_cat: '🙀 Différents',
571
+ js_label_thanks: 'Merci ! 🎓',
572
+ js_stats: (photos, labels) => `Pool : ${photos} photos · ${labels} réponses données`,
573
+ js_poster_reading: "Lecture de l'affiche…",
574
+ js_poster_loaded: 'Affiche chargée (lecture auto indisponible).',
575
+ js_photo_hint_few: n => `${n} photo(s) — ajoutes-en plus (vise 5+) pour bien reconnaître.`,
576
+ js_photo_hint_ok: n => `${n} photos — bien ! quelques-unes de plus = idéal.`,
577
+ js_photo_hint_great: n => `${n} photos — excellent ✅`,
578
+ js_gps_unavail: 'Géolocalisation indisponible',
579
+ js_gps_refused: 'Position refusée',
580
+ js_report_success: (name, photos, email) => `<div class="result match" style="margin-top:14px"><b>✅ ${esc(name||'Chat')} déclaré (${photos} photos)</b><p class="hint">Pour voir les signalements et le suivi : retourne à l'accueil → « Suivre mon signalement », entre ton courriel (${email}) et le code qu'on t'enverra. Rien à mémoriser 🙂</p></div>`,
581
+ js_choose_cat_title: name => `Tu reconnais ${name} ? Préviens le propriétaire 🐾`,
582
+ js_prog_embed: name => `🧠 ${name} crée l'empreinte visuelle du chat…`,
583
+ js_prog_compare: '🔎 Comparaison avec les chats perdus enregistrés…',
584
+ js_prog_save: photos => `🧠 ${photos} crée l'empreinte de ${photos}…`,
585
+ js_prog_saving: '💾 Enregistrement sécurisé de la fiche…',
586
+ js_date_locale: 'fr-CA',
587
+ js_num_locale: 'fr-CA',
588
+ js_code_sent: "Un code vient de t'être envoyé par courriel — vérifie ta boîte 📬",
589
+ },
590
+ en: {
591
+ home_sub: 'Community help to find our companions.',
592
+ tag_owner: 'Owner',
593
+ hero_lost_title: 'I lost my cat',
594
+ hero_lost_desc: 'Report their disappearance by sending a few photos (and a poster, if you have one). If a passerby takes a photo of them, our AI will recognize them.<br><br>• Immediate alert: You receive an email as soon as they\'re spotted.<br>• Privacy protected: You\'re the only one who decides to contact the finder. Your email and contact info are never shared.',
595
+ tag_finder: 'Passerby / Finder',
596
+ hero_seen_title: 'I saw a cat',
597
+ hero_seen_desc: 'Take a photo of the cat you came across who seems lost. The AI instantly compares their likeness with our database and shows you similar profiles.<br><br>• Quick report: If you recognize the cat, confirm the match to send an automatic and anonymous notification to their family.',
598
+ tag_follow: 'Tracking',
599
+ hero_follow_title: 'Track my report',
600
+ hero_follow_desc: 'Access your space to manage your alert or close the search.',
601
+ community_sub: 'Help the community:',
602
+ mini_poster_title: 'Share a found poster',
603
+ mini_poster_desc: 'Enrich the database',
604
+ mini_train_title: 'Help the AI',
605
+ mini_train_desc: 'Photos + recognition game',
606
+ faq_title: 'Frequently asked questions',
607
+ faq_q1: 'How does it work?',
608
+ faq_a1: 'You report your lost cat with photos. When someone comes across a cat, they take a photo: the AI compares it to lost cats and shows similarities. If the person recognizes your cat, you receive a report by email.',
609
+ faq_q2: 'Is my information private?',
610
+ faq_a2: 'Yes. No public directory: the person who spots a cat never sees your contact info. We reach you through a code sent to your email, and you can delete everything at any time.',
611
+ faq_q3: 'Is it free?',
612
+ faq_a3: 'Yes, entirely. It\'s a free community project with no advertising.',
613
+ faq_q4: 'What technology is behind it?',
614
+ faq_a4: 'Allo Chat uses <a href="https://huggingface.co/conservationxlabs/miewid-msv3" target="_blank" rel="noopener noreferrer">MiewID</a>, an AI model for animal re-identification that transforms each photo into a visual "fingerprint". We compare these fingerprints to spot similar-looking cats, then a human always confirms the match.',
615
+ title_home: '🐱 Lost Cats (Beta)',
616
+ title_check: '📷 I saw a cat',
617
+ title_report: '🚨 I lost my cat',
618
+ title_follow: 'ℹ️ Track my report',
619
+ title_poster: '📋 Share a poster',
620
+ title_train: '🧠 Help the AI',
621
+ title_legal: '⚖️ Legal notice & privacy',
622
+ check_privacy: "🔒 We only show cats that look like your photo. The owner's contact stays private — they will reach out to you.",
623
+ check_h2: 'Did you come across a cat?',
624
+ check_photo_label: 'Cat photo',
625
+ check_photo_btn: '📷 Take / choose a photo',
626
+ check_gps: '📍 Location: not captured (optional, helps sorting)',
627
+ getpos_btn: 'Get my location',
628
+ check_btn: '🔍 Check',
629
+ lead_title: 'Notify the owner 🐾',
630
+ lead_hint: 'Leave YOUR contact info: the owner will reach out to you.',
631
+ lead_contact_label: 'Your contact (phone / email)',
632
+ lead_msg_label: 'Message (where, when, cat\'s condition)',
633
+ lead_msg_placeholder: 'Seen this morning near the park',
634
+ lead_send_btn: 'Send to owner',
635
+ report_privacy: '🔒 Your contact stays private. You\'ll receive a <b>secret code</b> to track reports. More photos = better AI recognition.',
636
+ report_h2: 'Report my lost cat',
637
+ report_poster_label: 'Do you have a poster? (photo or PDF — optional)',
638
+ report_poster_btn: '🖼️ Upload poster (auto-fills form)',
639
+ report_name_label: "Cat's name",
640
+ report_name_placeholder: 'Whiskers',
641
+ report_desc_label: 'Description (coat, distinctive signs)',
642
+ report_desc_placeholder: 'Tortie, white locket, shy',
643
+ report_email_label: 'Your email (used to track reports — never shown to others)',
644
+ report_email_placeholder: 'you@example.com',
645
+ report_phone_label: 'Your phone (optional, private)',
646
+ report_details_label: 'Additional details (optional)',
647
+ report_details_placeholder: 'Missing since June 3, Rosemont area, blue collar…',
648
+ report_photos_label: 'Cat photos — <b>5 to 10 recommended</b> 🎯',
649
+ report_photos_btn: '📷 Add cat photos',
650
+ report_photo_hint: 'Tip: multiple angles and lighting = much more reliable.',
651
+ report_gps: '📍 Last known location: not captured',
652
+ report_getpos_btn: 'Get location',
653
+ report_btn: '🚨 Report (get my code)',
654
+ follow_privacy: "🔒 Secure access: we send you a code by email to verify your identity. You'll then see when you reported your cat, how many times the AI compared it, and reports received.",
655
+ follow_h2: 'Track my report',
656
+ follow_email_label: 'Your email (the one used to report)',
657
+ follow_email_placeholder: 'you@example.com',
658
+ follow_request_btn: '📩 Get my code',
659
+ follow_code_label: 'Code received',
660
+ follow_verify_btn: 'View my tracking',
661
+ poster_privacy: '📋 Posters are public. By sharing them, you enrich the database and help others find their cat.',
662
+ poster_h2: 'Share a found poster',
663
+ poster_file_label: 'Photo or PDF of the poster',
664
+ poster_file_btn: '📷 Take / upload poster',
665
+ poster_text_label: 'Info visible on the poster (optional)',
666
+ poster_text_placeholder: "Name, area, description… (what's written on the poster)",
667
+ poster_gps: '📍 Where did you see it? (optional)',
668
+ poster_btn: '📋 Add to database',
669
+ train_privacy: '🧠 Voluntary help. These photos are used ONLY to train the AI — they are never mixed with private lost cat profiles.',
670
+ train_photos_h2: 'Donate photos',
671
+ train_photos_hint: "Add cat photos (yours, or cats you've come across) to help the AI recognize better.",
672
+ train_photos_btn: '📷 Add photos',
673
+ train_consent: 'I agree that these images will be used to <b>train the recognition model</b>. They will not be mixed with lost cat profiles.',
674
+ train_photos_submit: 'Donate these photos',
675
+ train_game_h2: 'Game: same cat or not?',
676
+ train_game_hint: "Look at two photos and say if it's the same cat. Each answer helps the AI.",
677
+ train_game_start: 'Start playing',
678
+ lightbox_hint: 'Tap the image or press ✕ to close',
679
+ footer_html: "🐾 Made in Montréal · © 2026 Allo Chat · <a onclick=\"show('legal')\">Legal notice &amp; privacy</a><br>Contact: <a href=\"mailto:info@allochat.org\">info@allochat.org</a>",
680
+ legal_html: `<h2>Disclaimer</h2>
681
+ <p>Allo Chat is a <b>free</b> community service to help find lost cats in Montréal and beyond.</p>
682
+ <p><b>Service provided "as is", without warranty.</b> AI matches are <b>indicative only</b> and must always be confirmed by a human. Allo Chat does not guarantee that any cat will be found. <b>Use is at your own risk.</b></p>
683
+ <p><b>Limitation of liability.</b> Allo Chat shall not be liable for any direct or indirect damages resulting from the use of the service, user interactions, false matches, or service interruptions.</p>
684
+ <p><b>User conduct.</b> You are responsible for the information you publish. Exercise caution when contacting strangers, and do not approach a fearful or distressed animal.</p>
685
+ <h2 style="margin-top:22px">Privacy</h2>
686
+ <ul>
687
+ <li><b>Privacy officer:</b> Allo Chat team — <a href="mailto:info@allochat.org">info@allochat.org</a>.</li>
688
+ <li><b>Data collected:</b> cat photos, email (for tracking), phone (optional), description, approximate location (optional).</li>
689
+ <li><b>Sole purpose:</b> connect owners with people who spotted their cat. <b>No resale, no advertising, no profiling.</b></li>
690
+ <li><b>Privacy by default:</b> no public directory. The finder never sees your contact info — access requires a verification code sent to your email.</li>
691
+ <li><b>Right to erasure:</b> permanently delete your profile (photos, reports, contact) at any time via "Track my report".</li>
692
+ <li><b>Hosting:</b> servers in the United States, protected by security measures (restricted access, signed photo links, encrypted data in transit).</li>
693
+ <li><b>Your rights:</b> access, correction, deletion → <a href="mailto:info@allochat.org">info@allochat.org</a>.</li>
694
+ </ul>
695
+ <p class="hint" style="margin-top:14px">Last updated: June 2026.</p>`,
696
+ // JS dynamic strings
697
+ js_add_photo: 'Add a photo of the cat',
698
+ js_net_err: 'Network error — please retry',
699
+ js_rate: ' — too many requests, please wait a minute',
700
+ js_analysis_done: '✅ Analysis complete',
701
+ js_no_match: 'No match found',
702
+ js_match_q: 'Do you recognize any of them?',
703
+ js_match_count: n => `${n} cat(s) that look similar`,
704
+ js_conf_strong: 'Strong', js_conf_medium: 'Medium', js_conf_weak: 'Weak',
705
+ js_its_him: "That's them ✅",
706
+ js_view_poster: '🖼️ View poster',
707
+ js_no_poster_photo: 'No poster photo available.',
708
+ js_cat_default: 'Lost cat',
709
+ js_choose_cat_prompt: 'Choose a cat',
710
+ js_lead_sent: '<div class="result match"><div class="big">🙏</div><b>Thank you!</b><p class="hint">The owner has received your report.</p></div>',
711
+ js_email_required: 'Enter your email (to track reports)',
712
+ js_email_short: 'Enter your email',
713
+ js_code_invalid: '<p class="hint">Invalid or expired code. Request a new one.</p>',
714
+ js_no_cats: '<p class="hint">No cat linked to this email.</p>',
715
+ js_logout: 'Logged out 🔒',
716
+ js_logout_btn: '🔒 Log out',
717
+ js_report_label: 'Report',
718
+ js_no_message: '(no message)',
719
+ js_no_contact: '(no contact)',
720
+ js_no_leads: 'No reports yet.',
721
+ js_declared_on: 'reported on',
722
+ js_ai_compare: 'AI comparisons 🔎',
723
+ js_leads_received: 'report(s) received',
724
+ js_my_cat: 'My cat',
725
+ js_status: 'status',
726
+ js_mark_found: 'Mark as "found" 🎉',
727
+ js_delete: '🗑️ Delete permanently',
728
+ js_leads_title: n => `Reports received (${n})`,
729
+ js_map: '📍 map',
730
+ js_delete_confirm: (name) => `Permanently delete the profile for ${name}?\nPhotos, reports and contact will be erased. Irreversible.`,
731
+ js_this_profile: 'this profile',
732
+ js_deleted_toast: 'Profile deleted 🗑️',
733
+ js_deleted_html: '<p class="hint">Profile permanently deleted (right to erasure).</p>',
734
+ js_found_toast: 'Marked as found 🎉',
735
+ js_add_poster: 'Upload a poster',
736
+ js_poster_added: '<div class="result match"><div class="big">🙏</div><b>Thank you!</b><p class="hint">Poster added to the database.</p></div>',
737
+ js_add_photos: 'Add photos',
738
+ js_consent_required: 'Please check the consent box first 🙏',
739
+ js_sending: 'Sending…',
740
+ js_photos_donated: photos => `Thank you! ${photos} photos in the pool 🙏`,
741
+ js_loading: 'Loading…',
742
+ js_not_enough_photos: 'Not enough photos yet. Add some above first 🙂',
743
+ js_same_cat: '😺 Same cat',
744
+ js_diff_cat: '🙀 Different',
745
+ js_label_thanks: 'Thank you! 🎓',
746
+ js_stats: (photos, labels) => `Pool: ${photos} photos · ${labels} answers given`,
747
+ js_poster_reading: 'Reading poster…',
748
+ js_poster_loaded: 'Poster uploaded (auto-reading unavailable).',
749
+ js_photo_hint_few: n => `${n} photo(s) — add more (aim for 5+) for better recognition.`,
750
+ js_photo_hint_ok: n => `${n} photos — good! A few more would be ideal.`,
751
+ js_photo_hint_great: n => `${n} photos — excellent ✅`,
752
+ js_gps_unavail: 'Geolocation unavailable',
753
+ js_gps_refused: 'Location denied',
754
+ js_report_success: (name, photos, email) => `<div class="result match" style="margin-top:14px"><b>✅ ${esc(name||'Cat')} reported (${photos} photos)</b><p class="hint">To view reports and tracking: go back to home → "Track my report", enter your email (${email}) and the code we'll send. Nothing to memorize 🙂</p></div>`,
755
+ js_choose_cat_title: name => `Do you recognize ${name}? Notify the owner 🐾`,
756
+ js_prog_embed: name => `🧠 ${name} creating visual fingerprint…`,
757
+ js_prog_compare: '🔎 Comparing with registered lost cats…',
758
+ js_prog_saving: '💾 Securely saving your profile…',
759
+ js_date_locale: 'en-CA',
760
+ js_num_locale: 'en-CA',
761
+ js_code_sent: 'A code has been sent to your email — check your inbox 📬',
762
+ }
763
+ };
764
+
765
+ let LANG = localStorage.getItem('chats_lang') || 'fr';
766
+
767
+ function setLang(lang) {
768
+ LANG = lang;
769
+ localStorage.setItem('chats_lang', lang);
770
+ document.documentElement.lang = lang;
771
+ document.title = lang === 'en' ? 'Lost Cats (Beta) — community' : 'Chats perdus (Beta) — entraide';
772
+ const t = T[lang];
773
+ document.querySelectorAll('[data-i18n]').forEach(el => {
774
+ const k = el.dataset.i18n; if (t[k] !== undefined) el.textContent = t[k];
775
+ });
776
+ document.querySelectorAll('[data-i18n-html]').forEach(el => {
777
+ const k = el.dataset.i18nHtml; if (t[k] !== undefined) el.innerHTML = t[k];
778
+ });
779
+ document.querySelectorAll('[data-i18n-placeholder]').forEach(el => {
780
+ const k = el.dataset.i18nPlaceholder; if (t[k] !== undefined) el.placeholder = t[k];
781
+ });
782
+ // Legal section (full innerHTML)
783
+ document.getElementById('legal_content').innerHTML = t.legal_html;
784
+ // Footer
785
+ document.getElementById('footer_el').innerHTML = t.footer_html;
786
+ // Lang buttons
787
+ document.getElementById('btn_lang_fr').classList.toggle('on', lang === 'fr');
788
+ document.getElementById('btn_lang_en').classList.toggle('on', lang === 'en');
789
+ // Nav titles
790
+ Object.assign(TITLES, {
791
+ home: t.title_home, check: t.title_check, report: t.title_report,
792
+ follow: t.title_follow, poster: t.title_poster, train: t.title_train, legal: t.title_legal
793
+ });
794
+ const cur = ['home','check','report','follow','poster','train','legal']
795
+ .find(id => !document.getElementById(id).classList.contains('hidden')) || 'home';
796
+ document.getElementById('hTitle').textContent = TITLES[cur];
797
+ }
798
+
799
  // ---- navigation ----
800
+ const TITLES = {
801
+ home:'🐱 Chats perdus (version Beta)', check:"📷 J'ai vu un chat",
802
+ report:"🚨 J'ai perdu mon chat", follow:'ℹ️ Suivre mon signalement',
803
+ poster:'📋 Partager une affiche', train:"🧠 Aider l'IA",
804
+ legal:'⚖️ Avis légal & confidentialité'
805
+ };
806
  function show(v){
807
  ['home','check','report','follow','poster','train','legal'].forEach(id=>document.getElementById(id).classList.toggle('hidden',id!==v));
808
  document.getElementById('hTitle').textContent=TITLES[v];
 
813
  function goHome(){ show('home'); }
814
 
815
  function toast(m){const t=document.getElementById('toast');t.textContent=m;t.classList.add('show');setTimeout(()=>t.classList.remove('show'),2800);}
 
 
816
  function zoom(src){ document.getElementById('lightbox_img').src=src;
817
  document.getElementById('lightbox').classList.add('on'); }
 
818
  const zimg=url=>`<span class="zwrap"><img src="${encodeURI(url)}" onclick="zoom(this.src)" title="Voir en grand"><span class="zbadge">🔍</span></span>`;
819
  function closeZoom(e){ if(e) e.stopPropagation();
820
  const lb=document.getElementById('lightbox'); lb.classList.remove('on');
821
  document.getElementById('lightbox_img').removeAttribute('src'); }
822
  document.addEventListener('keydown',e=>{ if(e.key==='Escape') closeZoom(); });
823
  const val=id=>document.getElementById(id).value;
824
+ function getPos(p){
825
+ if(!navigator.geolocation) return toast(T[LANG].js_gps_unavail);
826
  navigator.geolocation.getCurrentPosition(g=>{pos[p]={lat:g.coords.latitude,lng:g.coords.longitude};
827
  document.getElementById(p+'_gps').textContent=`📍 ${g.coords.latitude.toFixed(5)}, ${g.coords.longitude.toFixed(5)}`;
828
+ },()=>toast(T[LANG].js_gps_refused));
829
  }
830
  function prev(inputId,boxId){
831
  const box=document.getElementById(boxId); box.innerHTML='';
 
835
  }
836
  }
837
 
838
+ let AI_NAME = LANG === 'en' ? 'the AI' : "l'IA";
 
 
839
  fetch(API+'/health').then(r=>r.json()).then(h=>{
840
  const e=(h.embedder||'');
841
+ AI_NAME = e.includes('miewid') ? 'MiewID' : e.includes('megadescriptor') ? 'MegaDescriptor' : (LANG==='en'?'the AI':"l'IA");
 
 
842
  }).catch(()=>{});
843
 
844
  function progress(id){
 
847
  let timer=null, finished=false;
848
  const set=(p,t)=>{ fill.style.width=Math.min(p,98)+'%'; if(t) txt.textContent=t; };
849
  return {
850
+ show(){ finished=false; box.classList.add('on'); set(2, LANG==='en'?'Preparing…':'Préparation…'); },
851
  set,
852
+ creep(target,t){ if(finished) return; if(t) txt.textContent=t;
 
853
  clearInterval(timer);
854
  timer=setInterval(()=>{ if(finished) return clearInterval(timer);
855
  const cur=parseFloat(fill.style.width)||0;
 
863
  };
864
  }
865
 
 
866
  function postWithProgress(url, fd, prog, stages){
867
  return new Promise((resolve,reject)=>{
868
  const xhr=new XMLHttpRequest();
869
  xhr.open('POST', url);
870
  xhr.upload.onprogress=e=>{ if(e.lengthComputable){
871
  const pct=e.loaded/e.total;
872
+ prog.set(2+Math.round(pct*38), (LANG==='en'?'📤 Sending… ':'📤 Envoi… ')+Math.round(pct*100)+'%'); } };
873
  xhr.upload.onload=()=>{ prog.creep(72, stages[0]);
874
  if(stages[1]) setTimeout(()=>prog.creep(93, stages[1]), 2600); };
875
  xhr.onload=()=>resolve({ok:xhr.status>=200&&xhr.status<300, status:xhr.status,
 
882
  // ---------- 1. VÉRIFIER ----------
883
  async function doCheck(){
884
  const f=document.getElementById('c_photo').files[0];
885
+ if(!f) return toast(T[LANG].js_add_photo);
886
  const fd=new FormData(); fd.append('photos',f);
887
  if(pos.c){fd.append('lat',pos.c.lat);fd.append('lng',pos.c.lng);}
888
  const prog=progress('c_prog'); prog.show();
889
+ const tl=T[LANG];
890
  let r;
891
  try{
892
  r=await postWithProgress(API+'/sightings', fd, prog, [
893
+ tl.js_prog_embed(AI_NAME), tl.js_prog_compare]);
894
+ }catch(e){ return prog.fail(tl.js_net_err); }
895
+ if(!r.ok){ return prog.fail('Erreur '+r.status+(r.status===429?tl.js_rate:'')); }
896
+ prog.done(tl.js_analysis_done);
 
897
  const d=r.json(); lastSightingId=d.sighting_id; lastMatches=d.matches||[];
898
  const box=document.getElementById('c_result'); document.getElementById('c_leadbox').classList.add('hidden');
899
  if(!d.match_found||!d.matches.length){
900
+ box.innerHTML=`<div class="result none"><div class="big">🔍</div><b>${tl.js_no_match}</b>
901
  <p class="hint">${esc(d.message)}</p></div>`; return;
902
  }
903
+ box.innerHTML=`<div class="card"><b>${tl.js_match_count(d.matches.length)}</b>
904
+ <p class="hint">${tl.js_match_q}</p>`+
905
  d.matches.map(m=>{
906
  const pct=Math.round(m.confidence*100);
907
+ const c=pct>=70?[tl.js_conf_strong,'var(--ok)']:pct>=40?[tl.js_conf_medium,'var(--warn)']:[tl.js_conf_weak,'var(--bad)'];
908
  const ph=(m.photos[0]&&m.photos[0].url)?API+m.photos[0].url:'';
909
+ const actionBtn = m.is_poster
910
+ ? `<button class="btn btn-primary" style="margin-top:8px" onclick="viewPoster('${m.candidate_token}')">${tl.js_view_poster}</button>`
911
+ : `<button class="btn btn-primary" style="margin-top:8px" onclick="chooseCat('${m.candidate_token}')">${tl.js_its_him}</button>`;
912
  return `<div class="candidate">${ph?zimg(ph):''}<div style="flex:1">
913
+ <b>${esc(m.name||tl.js_cat_default)}</b> <span class="pill" style="background:${c[1]}">${c[0]} ${pct}%</span><br>
914
  <span class="hint">${esc(m.description||'')}</span><br>
915
+ ${actionBtn}
916
  </div></div>`;
917
  }).join('')+`</div>`;
918
  }
919
  function chooseCat(id){ chosenCandidateToken=id;
920
  const m=lastMatches.find(x=>x.candidate_token===id);
921
+ const name=(m&&m.name)?m.name:(LANG==='en'?'this cat':'ce chat');
922
+ document.getElementById('c_leadtitle').textContent=T[LANG].js_choose_cat_title(name);
923
  const b=document.getElementById('c_leadbox'); b.classList.remove('hidden'); b.scrollIntoView({behavior:'smooth'});
924
  }
925
+ function viewPoster(token){
926
+ const m=lastMatches.find(x=>x.candidate_token===token); if(!m) return;
927
+ const ph=m.photos&&m.photos[0]&&m.photos[0].url ? API+m.photos[0].url : null;
928
+ if(ph) zoom(ph);
929
+ else toast(T[LANG].js_no_poster_photo);
930
+ }
931
  async function sendLead(){
932
+ if(!chosenCandidateToken) return toast(T[LANG].js_choose_cat_prompt);
933
  const r=await fetch(`${API}/sightings/${lastSightingId}/lead`,{method:'POST',headers:{'Content-Type':'application/json'},
934
  body:JSON.stringify({candidate_token:chosenCandidateToken,finder_contact:val('c_contact'),message:val('c_msg')})});
935
  if(!r.ok) return toast('Erreur '+r.status);
936
+ document.getElementById('c_leadbox').innerHTML=T[LANG].js_lead_sent;
937
  }
938
 
939
  // ---------- 2. SIGNALER ----------
940
  async function posterPrefill(inputId){
941
  const f=document.getElementById(inputId).files[0]; if(!f) return;
942
+ const note=document.getElementById('r_poster_note');
943
+ note.textContent=T[LANG].js_poster_reading;
944
  const fd=new FormData(); fd.append('file',f);
945
  try{
946
  const r=await fetch(API+'/posters',{method:'POST',body:fd}); const d=await r.json();
 
948
  const phEl=document.getElementById('r_phone');
949
  if(d.suggested_contact&&phEl) phEl.value=d.suggested_contact;
950
  if(d.suggested_description) document.getElementById('r_desc').value=d.suggested_description;
951
+ note.textContent=d.note||T[LANG].js_poster_loaded;
952
+ }catch(e){ note.textContent=T[LANG].js_poster_loaded; }
953
  }
954
  function photoHint(){
955
+ const n=document.getElementById('r_photos').files.length, h=document.getElementById('r_hint');
956
+ const tl=T[LANG];
957
+ if(!n){h.style.color='';h.textContent=tl.report_photo_hint;return;}
958
+ if(n<3){h.style.color='var(--bad)';h.textContent=tl.js_photo_hint_few(n);}
959
+ else if(n<5){h.style.color='var(--warn)';h.textContent=tl.js_photo_hint_ok(n);}
960
+ else{h.style.color='var(--ok)';h.textContent=tl.js_photo_hint_great(n);}
961
  }
962
  async function doReport(){
963
  const photos=document.getElementById('r_photos').files;
964
+ if(!photos.length) return toast(T[LANG].js_add_photo);
965
  const email=val('r_email').trim();
966
+ if(!email||!email.includes('@')) return toast(T[LANG].js_email_required);
967
  const fd=new FormData();
968
  fd.append('name',val('r_name'));
969
  const desc=[val('r_desc'),val('r_details')].filter(Boolean).join(' — ');
 
971
  if(pos.r){fd.append('last_seen_lat',pos.r.lat);fd.append('last_seen_lng',pos.r.lng);}
972
  for(const f of photos) fd.append('photos',f);
973
  const prog=progress('r_prog'); prog.show();
974
+ const tl=T[LANG];
975
  let r;
976
  try{
977
  r=await postWithProgress(API+'/cats', fd, prog, [
978
+ tl.js_prog_embed(AI_NAME+(photos.length>1?(LANG==='en'?` (${photos.length} photos)`:` (${photos.length} photos)`):'') ),
979
+ tl.js_prog_saving]);
980
+ }catch(e){ return prog.fail(tl.js_net_err); }
981
+ if(!r.ok){ return prog.fail('Erreur '+r.status+(r.status===429?tl.js_rate:'')); }
982
+ prog.done(tl.js_analysis_done);
983
  const d=r.json(); localStorage.setItem('chats_email',email);
984
  document.getElementById('r_track_email').value=email;
985
+ document.getElementById('r_token').innerHTML=tl.js_report_success(d.name, d.photos_count, email);
 
 
 
986
  document.getElementById('r_token').scrollIntoView({behavior:'smooth'});
987
  }
988
  async function requestCode(){
989
+ const email=val('r_track_email').trim();
990
+ if(!email||!email.includes('@')) return toast(T[LANG].js_email_short);
991
  const fd=new FormData(); fd.append('email',email);
992
  const r=await fetch(API+'/cats/request-code',{method:'POST',body:fd});
993
  if(!r.ok) return toast('Erreur '+r.status);
994
  const d=await r.json();
995
  document.getElementById('r_code_box').classList.remove('hidden');
996
+ if(d.dev_code){
997
  document.getElementById('r_track_code').value=d.dev_code;
998
+ toast('Demo: '+(LANG==='en'?'code pre-filled':'code pré-rempli')+' ('+d.dev_code+')');
999
+ } else toast(T[LANG].js_code_sent);
1000
  }
1001
  async function verifyCode(){
1002
  const email=val('r_track_email').trim(), code=val('r_track_code').trim();
1003
+ if(!code) return toast(T[LANG].follow_code_label);
1004
  const fd=new FormData(); fd.append('email',email); fd.append('code',code);
1005
  const r=await fetch(API+'/cats/verify-code',{method:'POST',body:fd});
1006
  const box=document.getElementById('r_track');
1007
+ if(!r.ok){ box.innerHTML=T[LANG].js_code_invalid; return; }
1008
  const acc=await r.json(); ownerCats=acc.cats||[];
1009
+ if(!acc.cats.length){ box.innerHTML=T[LANG].js_no_cats; return; }
1010
+ box.innerHTML=`<button class="btn btn-ghost" style="margin-top:0" onclick="followLogout()">${T[LANG].js_logout_btn}</button>`
1011
  + acc.cats.map(renderOwnerCat).join('');
1012
  }
1013
  function followLogout(){
 
1016
  document.getElementById('r_code_box').classList.add('hidden');
1017
  document.getElementById('r_track_email').value='';
1018
  localStorage.removeItem('chats_email');
1019
+ toast(T[LANG].js_logout); show('home');
1020
  }
1021
  function renderOwnerCat(c){
1022
+ const tl=T[LANG];
1023
  const leads=c.leads.map(l=>{
1024
  const pct=l.confidence?Math.round(l.confidence*100):null;
1025
  const img=l.sighting_photo_url?zimg(API+l.sighting_photo_url):'';
1026
+ const loc=(l.sighting_lat!=null)?`<a target="_blank" rel="noopener noreferrer" href="https://maps.google.com/?q=${Number(l.sighting_lat)},${Number(l.sighting_lng)}">${tl.js_map}</a>`:'';
1027
+ return `<div class="lead">${img}<div style="flex:1"><b>${tl.js_report_label}</b>${pct?` · ${pct}%`:''}<br>
1028
+ <span class="hint">${esc(l.message||tl.js_no_message)}</span><br>
1029
+ <span class="hint">📞 ${esc(l.finder_contact||tl.js_no_contact)} · ${loc}</span></div></div>`;
1030
+ }).join('')||`<p class="hint">${tl.js_no_leads}</p>`;
1031
  const declared = c.cat.last_seen_at || c.cat.created_at;
1032
+ const dDate = declared ? new Date(declared).toLocaleDateString(tl.js_date_locale,{day:'numeric',month:'long',year:'numeric'}) : '?';
1033
  const stats = `<div class="follow-stats">
1034
+ <div class="fs"><b>${dDate}</b><span>${tl.js_declared_on}</span></div>
1035
+ <div class="fs"><b>${(c.comparisons??0).toLocaleString(tl.js_num_locale)}</b><span>${tl.js_ai_compare}</span></div>
1036
+ <div class="fs"><b>${c.leads.length}</b><span>${tl.js_leads_received}</span></div>
1037
  </div>`;
1038
  return `<div style="margin-top:14px;border-top:1px solid var(--line);padding-top:12px">
1039
+ <b>${esc(c.cat.name||tl.js_my_cat)}</b> — ${tl.js_status} : ${esc(c.cat.status)}
1040
  ${stats}
1041
+ <button class="btn btn-ghost" onclick="reunited('${encodeURIComponent(c.claim_token)}')">${tl.js_mark_found}</button>
1042
  <button class="btn btn-ghost" style="color:var(--bad);border-color:var(--bad)"
1043
+ onclick="deleteCat('${encodeURIComponent(c.claim_token)}')">${tl.js_delete}</button>
1044
+ <h2 style="margin-top:12px">${tl.js_leads_title(c.leads.length)}</h2>${leads}</div>`;
1045
  }
1046
  async function deleteCat(tok){
1047
  const c=ownerCats.find(x=>x.claim_token===tok);
1048
+ const name=(c&&c.cat&&c.cat.name)?c.cat.name:T[LANG].js_this_profile;
1049
+ if(!confirm(T[LANG].js_delete_confirm(name))) return;
1050
  const fd=new FormData(); fd.append('token',tok);
1051
  const r=await fetch(API+'/cats/track/delete',{method:'POST',body:fd});
1052
  if(!r.ok) return toast('Erreur '+r.status);
1053
+ toast(T[LANG].js_deleted_toast);
1054
+ document.getElementById('r_track').innerHTML=T[LANG].js_deleted_html;
1055
  }
1056
  async function reunited(tok){
1057
  const fd=new FormData();fd.append('token',tok);fd.append('status','reunited');
1058
  const r=await fetch(API+'/cats/track/status',{method:'POST',body:fd});
1059
  if(!r.ok) return toast('Erreur');
1060
+ const c=await r.json(); toast(T[LANG].js_found_toast);
1061
  document.getElementById('r_track').innerHTML=renderOwnerCat(c);
1062
  }
1063
 
1064
  // ---------- 3. PARTAGER UNE AFFICHE ----------
1065
  async function doPoster(){
1066
  const f=document.getElementById('p_file').files[0];
1067
+ if(!f) return toast(T[LANG].js_add_poster);
 
1068
  const fd0=new FormData(); fd0.append('file',f);
1069
  try{ await fetch(API+'/posters',{method:'POST',body:fd0}); }catch(e){}
1070
+ const fd=new FormData(); fd.append('name', LANG==='en'?'Shared poster':'Affiche partagée'); fd.append('source','poster');
 
1071
  fd.append('description',val('p_text'));
1072
  if(pos.p){fd.append('last_seen_lat',pos.p.lat);fd.append('last_seen_lng',pos.p.lng);}
1073
  if(f.type.startsWith('image/')) fd.append('photos',f);
1074
  const r=await fetch(API+'/cats',{method:'POST',body:fd});
1075
  const box=document.getElementById('p_result');
1076
+ if(r.ok){ box.innerHTML=T[LANG].js_poster_added;
1077
  document.getElementById('p_file').value=''; document.getElementById('p_prev').innerHTML=''; document.getElementById('p_text').value='';
1078
  } else box.innerHTML='<p class="hint">Erreur '+r.status+'.</p>';
1079
  }
 
1081
  // ---------- 4. ENTRAÎNER ----------
1082
  async function doContribute(){
1083
  const photos=document.getElementById('t_photos').files;
1084
+ if(!photos.length) return toast(T[LANG].js_add_photos);
1085
+ if(!document.getElementById('t_consent').checked) return toast(T[LANG].js_consent_required);
1086
  const fd=new FormData(); fd.append('consent','1');
1087
  for(const f of photos) fd.append('photos',f);
1088
+ toast(T[LANG].js_sending);
1089
  const r=await fetch(API+'/training/photos',{method:'POST',body:fd});
1090
  if(!r.ok) return toast('Erreur '+r.status);
1091
+ const d=await r.json(); toast(T[LANG].js_photos_donated(d.photos));
1092
  document.getElementById('t_photos').value=''; document.getElementById('t_prev').innerHTML=''; loadTrainStats();
1093
  }
1094
  async function nextPair(){
1095
+ const g=document.getElementById('game'); g.innerHTML=`<p class="hint">${T[LANG].js_loading}</p>`;
1096
  const r=await fetch(API+'/training/pair');
1097
+ if(!r.ok){ g.innerHTML=`<p class="hint">${T[LANG].js_not_enough_photos}</p>`; return; }
1098
  gamePair=await r.json();
1099
  g.innerHTML=`<div class="game-pair"><img src="${encodeURI(API+gamePair.a.url)}" onclick="zoom(this.src)"><img src="${encodeURI(API+gamePair.b.url)}" onclick="zoom(this.src)"></div>
1100
  <div class="seg" style="margin-top:12px">
1101
+ <button onclick="sendLabel('same')">${T[LANG].js_same_cat}</button>
1102
+ <button onclick="sendLabel('different')">${T[LANG].js_diff_cat}</button>
1103
  </div>`;
1104
  }
1105
  async function sendLabel(v){
1106
  if(!gamePair) return;
1107
  await fetch(API+'/training/label',{method:'POST',headers:{'Content-Type':'application/json'},
1108
  body:JSON.stringify({photo_a_id:gamePair.a.id,photo_b_id:gamePair.b.id,verdict:v})});
1109
+ toast(T[LANG].js_label_thanks); loadTrainStats(); nextPair();
1110
  }
1111
  async function loadTrainStats(){
1112
  try{const d=await (await fetch(API+'/training/stats')).json();
1113
+ document.getElementById('t_stats').textContent=T[LANG].js_stats(d.photos, d.labels);}catch(e){}
1114
  }
1115
 
1116
+ // Init
1117
+ setLang(LANG);
1118
  const savedEmail=localStorage.getItem('chats_email'); if(savedEmail) document.getElementById('r_track_email').value=savedEmail;
1119
  </script>
1120
  </body>