Sarolanda commited on
Commit
18fd433
·
1 Parent(s): 17c32d2

Translates the app

Browse files
Files changed (3) hide show
  1. index.html +2 -1
  2. static/app.js +21 -2
  3. static/i18n.js +524 -0
index.html CHANGED
@@ -341,7 +341,7 @@
341
  </div>
342
  <div id="profile-ai-desc" class="ai-desc-callout" style="display:none">
343
  <span class="ai-label">AI</span>
344
- <p id="profile-ai-text"></p>
345
  </div>
346
 
347
  <!-- Identification -->
@@ -607,6 +607,7 @@
607
  </div><!-- /desktop-layout -->
608
 
609
  <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
 
610
  <script src="/static/app.js"></script>
611
  <script src="/static/help-proof.js"></script>
612
  <script>
 
341
  </div>
342
  <div id="profile-ai-desc" class="ai-desc-callout" style="display:none">
343
  <span class="ai-label">AI</span>
344
+ <p id="profile-ai-text" data-noi18n></p>
345
  </div>
346
 
347
  <!-- Identification -->
 
607
  </div><!-- /desktop-layout -->
608
 
609
  <script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>
610
+ <script src="/static/i18n.js"></script>
611
  <script src="/static/app.js"></script>
612
  <script src="/static/help-proof.js"></script>
613
  <script>
static/app.js CHANGED
@@ -338,10 +338,29 @@
338
  const aiText = desc.description_text || '';
339
  const condition = desc.condition || '';
340
  const marks = (desc.distinctive_marks || []).filter(Boolean);
341
- if (aiText) {
342
- let aiContent = aiText.charAt(0).toUpperCase() + aiText.slice(1);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
343
  if (condition) aiContent += ` · Condition: ${condition}`;
344
  if (marks.length) aiContent += ` · Marks: ${marks.join(', ')}`;
 
 
345
  aiTextEl.textContent = aiContent;
346
  aiDescEl.style.display = 'flex';
347
  } else {
 
338
  const aiText = desc.description_text || '';
339
  const condition = desc.condition || '';
340
  const marks = (desc.distinctive_marks || []).filter(Boolean);
341
+ const lang = window.PAWMAP_LANG || 'en';
342
+ const tr = window.PAWMAP_I18N;
343
+ let aiContent = '';
344
+ if (lang === 'pt' && tr) {
345
+ // The AI prompt is always English. Rather than machine-translating the
346
+ // free-text sentence, we rebuild a Portuguese description from the
347
+ // structured fields the AI returned (species/size/color/marks).
348
+ const sp = tr.species(isDog ? 'dog' : 'cat');
349
+ const sz = tr.size(size);
350
+ const co = tr.color(color);
351
+ let parts = sp;
352
+ if (sz && sz !== '—') parts += ` de porte ${sz.toLowerCase()}`;
353
+ if (co && co !== '—') parts += `, ${co.toLowerCase()}`;
354
+ aiContent = parts;
355
+ if (condition) aiContent += ` · Estado: ${tr.condition(condition)}`;
356
+ if (marks.length) aiContent += ` · Marcas: ${marks.map(tr.mark).join(', ')}`;
357
+ aiContent = aiContent.charAt(0).toUpperCase() + aiContent.slice(1);
358
+ } else if (aiText) {
359
+ aiContent = aiText.charAt(0).toUpperCase() + aiText.slice(1);
360
  if (condition) aiContent += ` · Condition: ${condition}`;
361
  if (marks.length) aiContent += ` · Marks: ${marks.join(', ')}`;
362
+ }
363
+ if (aiContent) {
364
  aiTextEl.textContent = aiContent;
365
  aiDescEl.style.display = 'flex';
366
  } else {
static/i18n.js ADDED
@@ -0,0 +1,524 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ─────────────────────────────────────────────────────────────────────────────
2
+ * i18n.js — PawMap lightweight runtime localizer (EN ⇄ PT-BR)
3
+ *
4
+ * How it works
5
+ * - English is the canonical language. A few strings are hardcoded in
6
+ * Portuguese in the source; the dictionary stores every pair as
7
+ * [English, Portuguese] so BOTH directions work:
8
+ * · PT mode: an English node → its Portuguese; a node already in
9
+ * Portuguese isn't an English key, so it's left untouched (stays PT).
10
+ * · EN mode: a Portuguese node → its English (via the reverse map);
11
+ * English nodes are native and left alone.
12
+ * - A set of regex PATTERNS (PT mode only) handles interpolated strings
13
+ * like "5 sightings" or "3d ago".
14
+ * - A MutationObserver re-applies translation to anything the app renders at
15
+ * runtime, so JS-generated text is covered.
16
+ * - Switching language persists the choice and reloads (the app re-fetches
17
+ * its data anyway), giving a clean re-render.
18
+ * - The AI free-text sentence is NOT machine-translated; in PT mode app.js
19
+ * rebuilds a description from the structured AI fields using the maps
20
+ * exposed here on window.PAWMAP_I18N. The AI prompt itself stays English.
21
+ * ──────────────────────────────────────────────────────────────────────────── */
22
+ (function () {
23
+ 'use strict';
24
+
25
+ var LANG_KEY = 'pawmap_lang';
26
+
27
+ function detectLang() {
28
+ var saved = localStorage.getItem(LANG_KEY);
29
+ if (saved === 'pt' || saved === 'en') return saved;
30
+ return (navigator.language || '').toLowerCase().indexOf('pt') === 0 ? 'pt' : 'en';
31
+ }
32
+
33
+ var LANG = detectLang();
34
+ window.PAWMAP_LANG = LANG;
35
+ document.documentElement.lang = LANG === 'pt' ? 'pt-BR' : 'en';
36
+
37
+ /* ── Word maps (reused in dictionary + exposed for AI-description rebuild) ── */
38
+ var SPECIES = { dog: 'Cachorro', cat: 'Gato' };
39
+ var COLORS = {
40
+ black: 'Preto', white: 'Branco', gray: 'Cinza', grey: 'Cinza', brown: 'Marrom',
41
+ chocolate: 'Chocolate', caramel: 'Caramelo', tan: 'Bege', golden: 'Dourado',
42
+ cream: 'Creme', fawn: 'Fulvo', orange: 'Laranja', ginger: 'Ruivo', red: 'Vermelho',
43
+ sable: 'Zibelina', cinnamon: 'Canela', blue: 'Azul', lilac: 'Lilás', tabby: 'Tigrado',
44
+ tuxedo: 'Smoking', calico: 'Calico', tortoiseshell: 'Escaminha', tricolor: 'Tricolor',
45
+ bicolor: 'Bicolor', brindle: 'Rajado', merle: 'Merle', spotted: 'Malhado', mixed: 'Misto'
46
+ };
47
+ var SIZES = {
48
+ tiny: 'Mini', small: 'Pequeno', medium: 'Médio', large: 'Grande',
49
+ 'extra large': 'Extra Grande', giant: 'Gigante'
50
+ };
51
+ var CONDITIONS = {
52
+ healthy: 'saudável', thin: 'magro', injured: 'ferido',
53
+ 'looks healthy': 'parece saudável', 'has collar': 'tem coleira'
54
+ };
55
+ var MARKS = {
56
+ collar: 'coleira', scar: 'cicatriz', patch: 'mancha', 'missing ear': 'orelha faltando',
57
+ 'floppy ears': 'orelhas caídas', wound: 'ferimento', spot: 'pinta', limp: 'mancando'
58
+ };
59
+
60
+ function ptColor(c) {
61
+ if (!c) return '';
62
+ var k = String(c).toLowerCase();
63
+ return COLORS[k] || (c.charAt(0).toUpperCase() + c.slice(1));
64
+ }
65
+ function ptSize(s) {
66
+ if (!s) return '';
67
+ return SIZES[String(s).toLowerCase()] || s;
68
+ }
69
+ function ptSpecies(sp) { return SPECIES[String(sp).toLowerCase()] || sp; }
70
+ function ptCondition(c) {
71
+ if (!c) return '';
72
+ var k = String(c).toLowerCase();
73
+ return CONDITIONS[k] || c;
74
+ }
75
+ function ptMark(m) {
76
+ if (!m) return '';
77
+ return MARKS[String(m).toLowerCase()] || m;
78
+ }
79
+
80
+ window.PAWMAP_I18N = {
81
+ get lang() { return window.PAWMAP_LANG; },
82
+ color: ptColor, size: ptSize, species: ptSpecies, condition: ptCondition, mark: ptMark
83
+ };
84
+
85
+ /* ── Dictionary: [English, Portuguese] (always this order) ── */
86
+ var PAIRS = [
87
+ // Onboarding / desktop sidebars
88
+ ['Collaborative stray animal mapping, powered by AI.', 'Mapeamento colaborativo de animais de rua, com IA.'],
89
+ ['The problem', 'O problema'],
90
+ ['Independent rescuers in Brasília, DF keep track of entire stray animal colonies in their heads — which ones were neutered, which one got hurt last winter, which one hasn\'t shown up this week. That knowledge lives in WhatsApp groups and fades when people move on.',
91
+ 'Protetores independentes em Brasília, DF guardam de cabeça colônias inteiras de animais de rua — quais foram castrados, qual se machucou no último inverno, qual não apareceu esta semana. Esse conhecimento vive em grupos de WhatsApp e se perde quando as pessoas seguem em frente.'],
92
+ ['PawMap turns those scattered sightings into a shared, persistent map — one photo at a time.',
93
+ 'O PawMap transforma esses avistamentos dispersos em um mapa compartilhado e permanente — uma foto de cada vez.'],
94
+ ['How it works', 'Como funciona'],
95
+ ['Snap a photo', 'Tire uma foto'],
96
+ ['Point your camera at any stray dog or cat.', 'Aponte a câmera para qualquer cão ou gato de rua.'],
97
+ ['AI identifies', 'A IA identifica'],
98
+ ['Species, breed, color and condition — automatically.', 'Espécie, raça, cor e estado — automaticamente.'],
99
+ ['Smart matching', 'Correspondência inteligente'],
100
+ ['Checks if this animal has been seen before.', 'Verifica se este animal já foi visto antes.'],
101
+ ['Map updates', 'O mapa atualiza'],
102
+ ['Sighting linked to the animal\'s profile — or a new one is created.', 'Avistamento ligado ao perfil do animal — ou um novo é criado.'],
103
+ ['Better on mobile', 'Melhor no celular'],
104
+ ['Skip', 'Pular'],
105
+ ['Step 1 of 4', 'Passo 1 de 4'],
106
+ ['Step 2 of 4', 'Passo 2 de 4'],
107
+ ['Step 3 of 4', 'Passo 3 de 4'],
108
+ ['Step 4 of 4', 'Passo 4 de 4'],
109
+ ['Help a stray find its way', 'Ajude um animal de rua a encontrar seu caminho'],
110
+ ['Every street dog and cat deserves to be seen. PawMap lets your community map them together — one photo at a time.',
111
+ 'Todo cão e gato de rua merece ser visto. O PawMap deixa sua comunidade mapeá-los juntos — uma foto de cada vez.'],
112
+ ['Snap a photo — AI does the rest', 'Tire uma foto — a IA faz o resto'],
113
+ ['Point your camera and tap. Our AI instantly identifies species, breed, color, and condition. No forms to fill.',
114
+ 'Aponte a câmera e toque. Nossa IA identifica na hora espécie, raça, cor e estado. Sem formulários para preencher.'],
115
+ ['AI matches the same animal', 'A IA reconhece o mesmo animal'],
116
+ ['Seen this dog before? The AI compares your photo with previous sightings and links them automatically — building each animal\'s story.',
117
+ 'Já viu este cão antes? A IA compara sua foto com avistamentos anteriores e os conecta automaticamente — construindo a história de cada animal.'],
118
+ ['Every map pin is a chance', 'Cada ponto no mapa é uma chance'],
119
+ ['Mapping a dog brings more chances to find a forever family — or get help from the community when it\'s needed most.',
120
+ 'Mapear um cão traz mais chances de encontrar uma família para sempre — ou de receber ajuda da comunidade na hora mais necessária.'],
121
+ ['Next →', 'Próximo →'],
122
+ ['Get Started', 'Começar'],
123
+ // Filter row
124
+ ['All', 'Todos'],
125
+ ['Dogs', 'Cachorros'],
126
+ ['Cats', 'Gatos'],
127
+ ['Today', 'Hoje'],
128
+ ['This Week', 'Esta semana'],
129
+ // Map / card
130
+ ['No sightings yet', 'Nenhum avistamento ainda'],
131
+ ['Dog', 'Cachorro'],
132
+ ['Cat', 'Gato'],
133
+ ['Dog spotted', 'Cachorro avistado'],
134
+ ['Cat spotted', 'Gato avistado'],
135
+ ['Yesterday', 'Ontem'],
136
+ ['View profile', 'Ver perfil'],
137
+ // Register
138
+ ['Add a photo to detect location', 'Adicione uma foto para detectar a localização'],
139
+ // reset() in app.js emits this exact PT sentence; pair it (PT→EN) so EN mode restores English.
140
+ // The duplicate English key is harmless: enToPt keeps the first, ptToEn gets this PT key.
141
+ ['Add a photo to detect location', 'Adicione uma foto para definir a localização'],
142
+ ['Tap the button to take a photo', 'Toque no botão para tirar uma foto'],
143
+ ['Add a note (optional)', 'Adicione uma nota (opcional)'],
144
+ ['Use current location', 'Usar localização atual'],
145
+ ['Select on map', 'Selecionar no mapa'],
146
+ ['Report Sighting', 'Registrar avistamento'],
147
+ ['Reading photo location…', 'Lendo a localização da foto…'],
148
+ ['No location in the photo — set it below', 'Sem localização na foto — defina abaixo'],
149
+ ['GPS not available', 'GPS indisponível'],
150
+ ['Detecting...', 'Detectando...'],
151
+ ['Permission denied', 'Permissão negada'],
152
+ ['Location unavailable', 'Localização indisponível'],
153
+ ['Timed out', 'Tempo esgotado'],
154
+ ['GPS error', 'Erro de GPS'],
155
+ // Analysis
156
+ ['Analyzing Sighting', 'Analisando avistamento'],
157
+ ['AI Analyzing...', 'IA analisando...'],
158
+ ['AI Done', 'IA concluída'],
159
+ ['Analysis error', 'Erro na análise'],
160
+ ['No animal found', 'Nenhum animal encontrado'],
161
+ ['Identified Details', 'Detalhes identificados'],
162
+ ['AI filled in these details. Correct if needed.', 'A IA preencheu estes detalhes. Corrija se precisar.'],
163
+ ['Species', 'Espécie'],
164
+ ['Breed (Estimated)', 'Raça (estimada)'],
165
+ ['Primary Color', 'Cor principal'],
166
+ ['Size', 'Porte'],
167
+ ['Condition (Optional)', 'Estado (opcional)'],
168
+ ['Injured', 'Ferido'],
169
+ ['Looks Healthy', 'Parece saudável'],
170
+ ['Has Collar', 'Tem coleira'],
171
+ ['Similar animals nearby', 'Animais parecidos por perto'],
172
+ ['Found by AI for context — recognition is automatic.', 'Encontrados pela IA para contexto — o reconhecimento é automático.'],
173
+ ['Name', 'Nome'],
174
+ ['optional', 'opcional'],
175
+ ['Registered', 'Registrado'],
176
+ ['Confirm & Report →', 'Confirmar e registrar →'],
177
+ ['Confirm without AI →', 'Confirmar sem IA →'],
178
+ ['Discard Photo', 'Descartar foto'],
179
+ ['← Take another photo', '← Tirar outra foto'],
180
+ ['Analyzing...', 'Analisando...'],
181
+ ['Saving...', 'Salvando...'],
182
+ ['Saving…', 'Salvando…'],
183
+ ['Error saving. Please try again.', 'Erro ao salvar. Tente novamente.'],
184
+ // help-proof.js emits this exact PT sentence; pair it (PT→EN) for EN mode.
185
+ ['Error saving. Please try again.', 'Erro ao registrar. Tente novamente.'],
186
+ // Color options
187
+ ['Black', 'Preto'], ['White', 'Branco'], ['Gray', 'Cinza'], ['Brown', 'Marrom'],
188
+ ['Chocolate', 'Chocolate'], ['Caramel', 'Caramelo'], ['Tan', 'Bege'], ['Golden', 'Dourado'],
189
+ ['Cream', 'Creme'], ['Fawn', 'Fulvo'], ['Orange', 'Laranja'], ['Ginger', 'Ruivo'],
190
+ ['Red', 'Vermelho'], ['Sable', 'Zibelina'], ['Cinnamon', 'Canela'], ['Blue (gray)', 'Azul (cinza)'],
191
+ ['Lilac', 'Lilás'], ['Tabby', 'Tigrado'], ['Tuxedo', 'Smoking'], ['Calico', 'Calico'],
192
+ ['Tortoiseshell', 'Escaminha'], ['Tricolor', 'Tricolor'], ['Bicolor', 'Bicolor'],
193
+ ['Brindle', 'Rajado'], ['Merle', 'Merle'], ['Spotted', 'Malhado'], ['Mixed', 'Misto'],
194
+ // Size options + labels
195
+ ['Tiny', 'Mini'], ['Small', 'Pequeno'], ['Medium', 'Médio'], ['Large', 'Grande'],
196
+ ['Extra Large', 'Extra Grande'], ['Giant', 'Gigante'],
197
+ ['↕ Tiny', '↕ Mini'], ['↕ Small', '↕ Pequeno'], ['↕ Medium', '↕ Médio'],
198
+ ['↕ Large', '↕ Grande'], ['↕ Extra Large', '↕ Extra Grande'], ['↕ Giant', '↕ Gigante'],
199
+ // Confirm screen
200
+ ['Sighting', 'Avistamento'],
201
+ ['Reported!', 'registrado!'],
202
+ ['Animal:', 'Animal:'],
203
+ ['Location:', 'Local:'],
204
+ ['Time:', 'Hora:'],
205
+ ['AI Identification', 'Identificação da IA'],
206
+ ['Breed', 'Raça'],
207
+ ['Color', 'Cor'],
208
+ ['Condition', 'Estado'],
209
+ ['Report Another', 'Registrar outro'],
210
+ ['View on Map', 'Ver no mapa'],
211
+ // Sightings list
212
+ ['Loading...', 'Carregando...'],
213
+ ['Failed to load. Try again.', 'Falha ao carregar. Tente novamente.'],
214
+ ['No animals on record yet.', 'Nenhum animal registrado ainda.'],
215
+ ['Go to the Report tab to get started!', 'Vá para a aba Registrar para começar!'],
216
+ ['Unknown breed', 'Raça desconhecida'],
217
+ ['Unknown', 'Desconhecida'],
218
+ ['today', 'hoje'],
219
+ ['yesterday', 'ontem'],
220
+ // Profile
221
+ ['This animal has been helped by the community', 'Este animal foi ajudado pela comunidade'],
222
+ ['Identification', 'Identificação'],
223
+ ['Sightings Gallery', 'Galeria de avistamentos'],
224
+ ['See all', 'Ver todos'],
225
+ ['Community Help', 'Ajuda da comunidade'],
226
+ ['Recent Path', 'Trajeto recente'],
227
+ ['I Want to Adopt / Help', 'Quero adotar / ajudar'],
228
+ ['Seen today', 'Visto hoje'],
229
+ ['Seen yesterday', 'Visto ontem'],
230
+ ['No photos recorded.', 'Nenhuma foto registrada.'],
231
+ ['Location not recorded', 'Local não registrado'],
232
+ ['No locations recorded', 'Nenhum local registrado'],
233
+ ['Failed to load', 'Falha ao carregar'],
234
+ // Help types
235
+ ['Fed', 'Alimentou'],
236
+ ['Took to vet', 'Levou ao veterinário'],
237
+ ['Adopted', 'Adotou'],
238
+ ['Rescued', 'Resgatou'],
239
+ ['Helped', 'Ajudou'],
240
+ ['Other', 'Outro'],
241
+ // Help sheet
242
+ ['Share this animal', 'Compartilhar este animal'],
243
+ ['Send via WhatsApp or copy link', 'Enviar pelo WhatsApp ou copiar link'],
244
+ ['Get directions', 'Ver rota'],
245
+ ['Navigate to last known location', 'Navegar até o último local conhecido'],
246
+ ['I helped this animal', 'Eu ajudei este animal'],
247
+ ['Log that you fed, treated, or fostered them', 'Registre que você alimentou, tratou ou acolheu'],
248
+ ['Cancel', 'Cancelar'],
249
+ ['No location recorded for this animal yet.', 'Nenhum local registrado para este animal ainda.'],
250
+ // Helped confirm
251
+ ['Thank you', 'Obrigada'],
252
+ ['Your help has been recorded.', 'Sua ajuda foi registrada.'],
253
+ ['Every act of care makes a difference.', 'Cada gesto de cuidado faz diferença.'],
254
+ ['Done', 'Concluído'],
255
+ // Source chooser (hardcoded PT in source)
256
+ ['Take photo', 'Tirar foto'],
257
+ ['Open the camera', 'Abrir a câmera'],
258
+ ['Choose from gallery', 'Escolher da galeria'],
259
+ ['Select an existing photo', 'Selecionar uma foto existente'],
260
+ // Map picker (hardcoded PT in source)
261
+ ['Where did you see the animal?', 'Onde você viu o animal?'],
262
+ ['Search address or neighborhood…', 'Buscar endereço ou bairro…'],
263
+ ['Use my location', 'Usar minha localização'],
264
+ ['Drag the map to position the pin', 'Arraste o mapa para posicionar o pin'],
265
+ ['Confirm location', 'Confirmar localização'],
266
+ // Help proof
267
+ ['I Helped This Animal', 'Eu ajudei este animal'],
268
+ ['What did you do?', 'O que você fez?'],
269
+ ['Add a photo', 'Adicione uma foto'],
270
+ ['Tap to add photo proof', 'Toque para adicionar uma foto de prova'],
271
+ ['Verifying with AI…', 'Verificando com IA…'],
272
+ ['Same animal confirmed', 'Mesmo animal confirmado'],
273
+ ['Likely the same animal', 'Provavelmente o mesmo animal'],
274
+ ['Photo added — animal not matched', 'Foto adicionada — animal não correspondido'],
275
+ ['No animal detected in photo', 'Nenhum animal detectado na foto'],
276
+ ['Tell the story', 'Conte a história'],
277
+ ['What happened? Where did you take them?', 'O que aconteceu? Para onde você o levou?'],
278
+ ['Register Help', 'Registrar ajuda'],
279
+ // Photo menu
280
+ ['Download photo', 'Baixar foto'],
281
+ // Bottom nav
282
+ ['Map', 'Mapa'],
283
+ ['Report', 'Registrar'],
284
+ ['Sightings', 'Avistamentos'],
285
+ // Right sidebar
286
+ ['Built for', 'Feito para'],
287
+ ['Backyard AI track · Hugging Face', 'Trilha Backyard AI · Hugging Face'],
288
+ ['Tech stack', 'Tecnologias'],
289
+ ['Vision AI', 'IA de visão'],
290
+ ['Embeddings', 'Embeddings'],
291
+ ['Matching', 'Correspondência'],
292
+ ['Frontend', 'Frontend'],
293
+ ['Storage', 'Armazenamento'],
294
+ ['Help an animal today', 'Ajude um animal hoje'],
295
+ ['Live Space ↗', 'Space ao vivo ↗'],
296
+ ['Dataset ↗', 'Dataset ↗'],
297
+ // aria / titles
298
+ ['Menu', 'Menu'],
299
+ ['Options', 'Opções'],
300
+ ['Close', 'Fechar'],
301
+ ['Refresh', 'Atualizar']
302
+ ];
303
+
304
+ /* ── Build lookup maps ── */
305
+ var enToPt = Object.create(null);
306
+ var ptToEn = Object.create(null);
307
+ PAIRS.forEach(function (p) {
308
+ var en = p[0], pt = p[1];
309
+ if (!(en in enToPt)) enToPt[en] = pt;
310
+ if (!(pt in ptToEn)) ptToEn[pt] = en;
311
+ });
312
+
313
+ /* ── Helpers for interpolated strings (EN → PT, PT mode only) ── */
314
+ function plural(n, sing, plur) { return n === 1 ? sing : plur; }
315
+ function dayPhrasePt(s) {
316
+ if (s === 'today') return 'hoje';
317
+ if (s === 'yesterday') return 'ontem';
318
+ var m = s.match(/^(\d+) days ago$/);
319
+ if (m) return 'há ' + m[1] + ' dias';
320
+ return s;
321
+ }
322
+
323
+ var PATTERNS = [
324
+ [/^(\d+)d ago$/, function (m) { return 'há ' + m[1] + 'd'; }],
325
+ [/^(\d+) days ago$/, function (m) { return 'há ' + m[1] + ' dias'; }],
326
+ [/^Seen (\d+) days ago$/, function (m) { return 'Visto há ' + m[1] + ' dias'; }],
327
+ [/^Not seen for (\d+)d$/, function (m) { return 'Sem ser visto há ' + m[1] + 'd'; }],
328
+ [/^(\d+) animals? on record$/, function (m) {
329
+ var n = +m[1]; return n + ' ' + plural(n, 'animal registrado', 'animais registrados');
330
+ }],
331
+ [/^(\d+) sightings?$/, function (m) {
332
+ var n = +m[1]; return n + ' ' + plural(n, 'avistamento', 'avistamentos');
333
+ }],
334
+ [/^(\d+) sightings? · last: (.+)$/, function (m) {
335
+ var n = +m[1]; return n + ' ' + plural(n, 'avistamento', 'avistamentos') + ' · último: ' + m[2];
336
+ }],
337
+ [/^Seen (.+) · (\d+)x spotted$/, function (m) {
338
+ return 'Visto ' + dayPhrasePt(m[1]) + ' · ' + m[2] + 'x avistado';
339
+ }],
340
+ [/^Animal #(\d+)$/, function (m) { return 'Animal nº' + m[1]; }],
341
+ [/^Dog #(\d+)$/, function (m) { return 'Cachorro nº' + m[1]; }],
342
+ [/^Cat #(\d+)$/, function (m) { return 'Gato nº' + m[1]; }],
343
+ // "Dog Caramel" / "Cat White" — species + known color
344
+ [/^(Dog|Cat) (.+)$/, function (m) {
345
+ var col = m[2].toLowerCase();
346
+ if (!COLORS[col]) return null;
347
+ return ptSpecies(m[1].toLowerCase()) + ' ' + COLORS[col];
348
+ }],
349
+ // GPS coords with an English source label appended
350
+ [/^(.+) · from photo$/, function (m) { return m[1] + ' · da foto'; }],
351
+ // share text
352
+ [/^Help this stray animal find a home! Track their location on PawMap: (.+)$/, function (m) {
353
+ return 'Ajude este animal de rua a encontrar um lar! Acompanhe a localização no PawMap: ' + m[1];
354
+ }]
355
+ ];
356
+
357
+ /* ── Core translation for a string ── */
358
+ function translateString(text) {
359
+ var key = text.trim();
360
+ if (!key) return null;
361
+ if (LANG === 'pt') {
362
+ if (enToPt[key] !== undefined && enToPt[key] !== key) return rewrap(text, key, enToPt[key]);
363
+ for (var i = 0; i < PATTERNS.length; i++) {
364
+ var m = key.match(PATTERNS[i][0]);
365
+ if (m) {
366
+ var out = PATTERNS[i][1](m);
367
+ if (out != null && out !== key) return rewrap(text, key, out);
368
+ }
369
+ }
370
+ return null;
371
+ } else { // EN: only fix hardcoded-PT source strings
372
+ if (ptToEn[key] !== undefined && ptToEn[key] !== key) return rewrap(text, key, ptToEn[key]);
373
+ return null;
374
+ }
375
+ }
376
+ // preserve any leading/trailing whitespace of the original node text
377
+ function rewrap(orig, trimmed, replacement) {
378
+ var idx = orig.indexOf(trimmed);
379
+ if (idx < 0) return replacement;
380
+ return orig.slice(0, idx) + replacement + orig.slice(idx + trimmed.length);
381
+ }
382
+
383
+ /* ── DOM walking ── */
384
+ var SKIP_TAGS = { SCRIPT: 1, STYLE: 1, NOSCRIPT: 1, TEXTAREA: 1 };
385
+ function shouldSkip(node) {
386
+ var p = node.parentNode;
387
+ while (p && p.nodeType === 1) {
388
+ if (SKIP_TAGS[p.tagName]) return true;
389
+ if (p.hasAttribute && p.hasAttribute('data-noi18n')) return true;
390
+ if (p.classList && p.classList.contains('leaflet-container')) return true;
391
+ p = p.parentNode;
392
+ }
393
+ return false;
394
+ }
395
+
396
+ var applying = false;
397
+ function localizeTextNode(node) {
398
+ if (!node.nodeValue) return;
399
+ if (shouldSkip(node)) return;
400
+ var out = translateString(node.nodeValue);
401
+ if (out != null && out !== node.nodeValue) node.nodeValue = out;
402
+ }
403
+
404
+ var ATTRS = ['placeholder', 'title', 'aria-label'];
405
+ function localizeAttrs(el) {
406
+ if (!el || !el.getAttribute) return;
407
+ if (el.hasAttribute('data-noi18n')) return;
408
+ for (var i = 0; i < ATTRS.length; i++) {
409
+ var v = el.getAttribute(ATTRS[i]);
410
+ if (!v) continue;
411
+ var out = translateString(v);
412
+ if (out != null && out !== v) el.setAttribute(ATTRS[i], out);
413
+ }
414
+ }
415
+
416
+ function walk(root) {
417
+ if (root.nodeType === 3) { localizeTextNode(root); return; }
418
+ if (root.nodeType !== 1) return;
419
+ if (SKIP_TAGS[root.tagName]) return;
420
+ if (root.classList && root.classList.contains('leaflet-container')) return;
421
+ if (root.hasAttribute && root.hasAttribute('data-noi18n')) return;
422
+ localizeAttrs(root);
423
+ var tw = document.createTreeWalker(root, NodeFilter.SHOW_TEXT, null, false);
424
+ var batch = [];
425
+ var n;
426
+ while ((n = tw.nextNode())) batch.push(n);
427
+ batch.forEach(localizeTextNode);
428
+ var els = root.querySelectorAll('[placeholder],[title],[aria-label]');
429
+ for (var i = 0; i < els.length; i++) localizeAttrs(els[i]);
430
+ }
431
+
432
+ function run(fn) {
433
+ if (applying) return fn();
434
+ applying = true;
435
+ try { fn(); } finally { applying = false; }
436
+ }
437
+
438
+ /* ── Initial sweep + observer ── */
439
+ function initialSweep() {
440
+ run(function () { walk(document.body); });
441
+ }
442
+
443
+ function startObserver() {
444
+ var appEl = document.getElementById('app');
445
+ if (!appEl) return;
446
+ var obs = new MutationObserver(function (mutations) {
447
+ if (applying) return;
448
+ run(function () {
449
+ for (var i = 0; i < mutations.length; i++) {
450
+ var mu = mutations[i];
451
+ if (mu.type === 'characterData') {
452
+ localizeTextNode(mu.target);
453
+ } else if (mu.type === 'attributes') {
454
+ localizeAttrs(mu.target);
455
+ } else {
456
+ for (var j = 0; j < mu.addedNodes.length; j++) walk(mu.addedNodes[j]);
457
+ }
458
+ }
459
+ });
460
+ });
461
+ obs.observe(appEl, {
462
+ childList: true, subtree: true, characterData: true,
463
+ attributes: true, attributeFilter: ATTRS
464
+ });
465
+ }
466
+
467
+ /* ── Language toggle UI (the top-right three-dots button) ── */
468
+ function buildToggle() {
469
+ var header = document.getElementById('header');
470
+ if (!header) return;
471
+ var btns = header.querySelectorAll('.icon-btn');
472
+ var optBtn = btns.length ? btns[btns.length - 1] : null; // "Options" (three-dots) button
473
+ if (!optBtn) return;
474
+
475
+ var menu = document.createElement('div');
476
+ menu.id = 'lang-menu';
477
+ menu.setAttribute('data-noi18n', '');
478
+ menu.style.cssText =
479
+ 'position:absolute;top:52px;right:10px;background:#fff;border-radius:12px;' +
480
+ 'box-shadow:0 8px 28px rgba(0,0,0,.18);overflow:hidden;z-index:1200;display:none;' +
481
+ 'min-width:150px;font-family:inherit;border:1px solid rgba(0,0,0,.06);';
482
+ function row(code, label) {
483
+ var active = LANG === code;
484
+ return '<button data-lang="' + code + '" style="display:flex;align-items:center;gap:8px;width:100%;' +
485
+ 'border:none;background:' + (active ? '#F1F8F3' : '#fff') + ';padding:11px 14px;font-size:14px;' +
486
+ 'color:#222;cursor:pointer;text-align:left;font-weight:' + (active ? '600' : '400') + ';">' +
487
+ '<span style="width:16px;display:inline-block;color:#388C59;">' + (active ? '✓' : '') + '</span>' +
488
+ label + '</button>';
489
+ }
490
+ menu.innerHTML = row('pt', 'Português') + row('en', 'English');
491
+ var app = document.getElementById('app');
492
+ (app || document.body).appendChild(menu);
493
+
494
+ function close() { menu.style.display = 'none'; }
495
+ optBtn.addEventListener('click', function (e) {
496
+ e.stopPropagation();
497
+ menu.style.display = menu.style.display === 'block' ? 'none' : 'block';
498
+ });
499
+ document.addEventListener('click', function (e) {
500
+ if (e.target.closest && e.target.closest('#lang-menu')) return;
501
+ close();
502
+ });
503
+ menu.addEventListener('click', function (e) {
504
+ var b = e.target.closest('[data-lang]');
505
+ if (!b) return;
506
+ var code = b.getAttribute('data-lang');
507
+ if (code === LANG) { close(); return; }
508
+ localStorage.setItem(LANG_KEY, code);
509
+ location.reload();
510
+ });
511
+ }
512
+
513
+ /* ── Boot ── */
514
+ function boot() {
515
+ buildToggle();
516
+ initialSweep();
517
+ startObserver();
518
+ }
519
+ if (document.readyState === 'loading') {
520
+ document.addEventListener('DOMContentLoaded', boot);
521
+ } else {
522
+ boot();
523
+ }
524
+ })();