sara.mesquita commited on
Commit
0cfcf78
Β·
1 Parent(s): ad56f8a

adjusts ia

Browse files
Files changed (2) hide show
  1. app.py +245 -79
  2. core/database.py +10 -0
app.py CHANGED
@@ -23,30 +23,29 @@ db = Database()
23
  ai = AnimalAI()
24
  matcher = AnimalMatcher()
25
 
26
- C_GREEN = "#388C59"
27
- C_GREEN_L = "#D9EBD9"
28
- C_TEXT = "#212121"
29
- C_RED = "#E53935"
30
- C_ORANGE = "#FB8C00"
31
 
32
  HEAD_HTML = """
33
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" crossorigin="anonymous">
34
  <script>
35
  (function() {
36
- function updateGradioTextbox(elementId, value) {
 
37
  var attempts = 0;
38
- function tryUpdate() {
39
- var container = document.getElementById(elementId);
40
- if (!container) { if (attempts++ < 20) setTimeout(tryUpdate, 300); return; }
41
- var tb = container.querySelector('textarea') || container.querySelector('input[type="text"]');
42
- if (!tb) { if (attempts++ < 20) setTimeout(tryUpdate, 300); return; }
43
- var proto = (tb.tagName === 'TEXTAREA') ? window.HTMLTextAreaElement.prototype : window.HTMLInputElement.prototype;
44
  var setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
45
- setter.call(tb, value);
46
  tb.dispatchEvent(new Event('input', { bubbles: true }));
47
  tb.dispatchEvent(new Event('change', { bubbles: true }));
48
  }
49
- tryUpdate();
50
  }
51
 
52
  function requestGPS() {
@@ -58,46 +57,53 @@ HEAD_HTML = """
58
  btn.disabled = true;
59
  btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Detectando...';
60
  icon.innerHTML = '<i class="fa-solid fa-spinner fa-spin" style="color:#388C59"></i>';
61
- text.textContent = 'Aguardando GPS...';
62
  navigator.geolocation.getCurrentPosition(
63
  function(pos) {
64
  var lat = parseFloat(pos.coords.latitude.toFixed(5));
65
  var lng = parseFloat(pos.coords.longitude.toFixed(5));
66
  icon.innerHTML = '<i class="fa-solid fa-check" style="color:#388C59"></i>';
67
- text.innerHTML = '<strong>Localizacao:</strong> ' + lat + ', ' + lng;
68
  btn.innerHTML = '<i class="fa-solid fa-rotate"></i> Atualizar';
69
  btn.disabled = false;
70
  btn.style.background = '#2d7a4a';
71
- updateGradioTextbox('gps-coords', JSON.stringify({ lat: lat, lng: lng }));
72
  },
73
  function(err) {
74
  icon.innerHTML = '<i class="fa-solid fa-triangle-exclamation" style="color:#E53935"></i>';
75
  btn.innerHTML = '<i class="fa-solid fa-location-dot"></i> Tentar novamente';
76
  btn.disabled = false;
77
  btn.style.background = '#E53935';
78
- var msgs = { 1:'Permissao negada.', 2:'Localizacao indisponivel.', 3:'Timeout GPS.' };
79
  text.textContent = msgs[err.code] || 'Erro: ' + err.message;
80
- updateGradioTextbox('gps-coords', JSON.stringify({}));
81
  },
82
  { enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
83
  );
84
  }
85
-
86
  window.animalVistoRequestGPS = requestGPS;
87
-
88
  document.addEventListener('click', function(e) {
89
- var tabBtn = e.target.closest('.tab-nav button');
90
- if (tabBtn && tabBtn.textContent.includes('Registrar')) setTimeout(requestGPS, 600);
91
  });
92
 
93
- function patchCameraInput() {
 
94
  var el = document.getElementById('photo-upload');
95
- if (!el) { setTimeout(patchCameraInput, 800); return; }
96
  var inp = el.querySelector('input[type=file]');
97
  if (inp) { inp.setAttribute('capture', 'environment'); inp.setAttribute('accept', 'image/*'); }
98
- else setTimeout(patchCameraInput, 800);
99
  }
100
- setTimeout(patchCameraInput, 1500);
 
 
 
 
 
 
 
 
 
101
  })();
102
  </script>
103
  """
@@ -122,25 +128,52 @@ footer { display: none !important; }
122
  .reg-label i { color: #388C59; }
123
  #photo-upload { border: 2px dashed #cfcfcf !important; border-radius: 12px !important; background: #f9f9f9 !important; min-height: 180px; }
124
  #submit-btn { margin-top: 10px !important; width: 100% !important; background: #388C59 !important; border: none !important; border-radius: 12px !important; font-size: 16px !important; font-weight: 600 !important; padding: 14px !important; color: white !important; }
125
- #status-card { margin-top: 16px; border-radius: 12px; font-size: 14px; line-height: 1.5; }
126
- #status-card.ok { background: #e8f5e9; color: #2e7d32; border-left: 4px solid #388C59; padding: 14px 16px; }
127
- #status-card.err { background: #ffebee; color: #b71c1c; border-left: 4px solid #E53935; padding: 14px 16px; }
128
  #gps-coords { display: none !important; }
 
129
  #animals-tab { padding: 0 0 80px; background: white; }
130
- .animal-card { display: flex; align-items: center; gap: 12px; background: white; border-radius: 12px; padding: 14px 16px; margin: 8px 12px; box-shadow: 0 2px 8px rgba(0,0,0,.07); border: 1px solid #f0f0f0; }
131
- .animal-thumb { width: 52px; height: 52px; border-radius: 10px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 24px; overflow: hidden; background: #eee; }
 
 
132
  .animal-info { flex: 1; min-width: 0; }
133
  .animal-name { font-weight: 600; font-size: 14px; color: #212121; }
134
  .animal-meta { font-size: 12px; color: #888; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
135
  .animal-badge { background: #D9EBD9; color: #388C59; border-radius: 20px; padding: 3px 9px; font-size: 12px; font-weight: 700; flex-shrink: 0; }
136
  .animal-badge.urgent { background: #ffebee; color: #E53935; }
137
  .section-header { padding: 14px 16px 6px; font-size: 13px; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: .5px; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
138
  """
139
 
140
  GPS_HTML = """
141
  <div id="gps-box" style="display:flex;align-items:center;gap:10px;background:#D9EBD9;border-radius:10px;padding:12px 14px;font-size:13px;color:#2e7d32;font-weight:500;border:1px solid #c8e6c9;">
142
  <span id="gps-icon" style="font-size:18px"><i class="fa-solid fa-location-crosshairs"></i></span>
143
- <span id="gps-text" style="flex:1">Toque para detectar localizacao</span>
144
  <button id="gps-btn" onclick="window.animalVistoRequestGPS && window.animalVistoRequestGPS()"
145
  style="background:#388C59;color:white;border:none;padding:6px 14px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap;">
146
  <i class="fa-solid fa-location-dot"></i> Localizar
@@ -148,32 +181,34 @@ GPS_HTML = """
148
  </div>
149
  """
150
 
 
 
 
 
 
 
 
 
 
 
151
 
152
  def build_map_html(species="all", timeframe="all"):
153
  import base64 as _b64
154
  data = db.get_map_data(species, timeframe)
155
- # Base64 evita conflito de aspas e escaping no srcdoc
156
  data_b64 = _b64.b64encode(json.dumps(data, ensure_ascii=False).encode()).decode()
157
- # Leaflet via cdnjs (mais estΓ‘vel que unpkg no HuggingFace)
158
  inner = (
159
  '<!DOCTYPE html><html><head>'
160
  '<meta charset="utf-8"/>'
161
  '<meta name="viewport" content="width=device-width,initial-scale=1"/>'
162
  '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css"/>'
163
- '<style>body{margin:0;padding:0;}#map{height:100vh;width:100%;}'
164
- '.popup-photo{width:100%;height:80px;object-fit:cover;border-radius:6px;margin-bottom:6px;}'
165
- '</style></head><body>'
166
  '<div id="map"></div>'
167
  '<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>'
168
  '<script>'
169
  'var animals=JSON.parse(atob("' + data_b64 + '"));'
170
  'var map=L.map("map").setView([-23.0316,-46.9785],13);'
171
  'L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",{attribution:"OSM",maxZoom:19}).addTo(map);'
172
- 'if(!animals.length){'
173
- ' var info=L.control({position:"topright"});'
174
- ' info.onAdd=function(){var d=L.DomUtil.create("div");d.style="background:white;padding:8px 12px;border-radius:8px;font-size:13px;color:#888;";d.innerHTML="Nenhum avistamento ainda";return d;};'
175
- ' info.addTo(map);'
176
- '}'
177
  'animals.forEach(function(a){'
178
  ' var color=a.days_since>30?"#E53935":a.count>1?"#FB8C00":"#388C59";'
179
  ' var em=a.species==="dog"?"D":"G";'
@@ -181,12 +216,10 @@ def build_map_html(species="all", timeframe="all"):
181
  ' var ico=L.divIcon({html:"<div style=\'position:relative;background:"+color+";width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:700;color:white;box-shadow:0 2px 8px rgba(0,0,0,.3);border:2.5px solid white;\'>"+em+badge+"</div>",className:"",iconSize:[38,38],iconAnchor:[19,19],popupAnchor:[0,-22]});'
182
  ' var sp=a.species==="dog"?"Cao":"Gato";'
183
  ' var urg=a.days_since>30?"<br><small style=\'color:#E53935\'>Nao visto ha "+a.days_since+" dias</small>":"";'
184
- ' var popup="<b>"+sp+" #"+a.id+"</b>"+(a.desc?"<br><small>"+a.desc+"</small>":"")+"<br><small>"+a.count+"x - "+a.last_seen+"</small>"+urg;'
185
- ' L.marker([a.lat,a.lng],{icon:ico}).addTo(map).bindPopup(popup,{maxWidth:220});'
186
  '});'
187
  '</script></body></html>'
188
  )
189
- # srcdoc: só escapa & e " (NÃO escapa < e > para não quebrar o HTML)
190
  srcdoc = inner.replace('&', '&amp;').replace('"', '&quot;')
191
  return f'<iframe srcdoc="{srcdoc}" style="width:100%;height:500px;border:none;"></iframe>'
192
 
@@ -194,37 +227,36 @@ def build_map_html(species="all", timeframe="all"):
194
  def build_animals_html():
195
  animals = db.get_recent_animals(limit=30)
196
  if not animals:
197
- return '<div style="padding:40px 16px;text-align:center;color:#aaa;"><div style="font-size:48px">🐾</div><div style="font-size:15px;font-weight:500;margin-top:12px;">Nenhum animal registrado ainda</div></div>'
198
  cards = []
199
  for a in animals:
200
  try:
201
  desc = json.loads(a.get("description") or "{}")
202
  except Exception:
203
  desc = {}
204
- is_dog = a["species"] == "dog"
205
- em = "πŸ•" if is_dog else "🐈"
206
- sp_pt = "Cao" if is_dog else "Gato"
207
- urgent = a.get("days_since", 0) > 30
208
- color = C_RED if urgent else C_GREEN
209
  badge_cls = "animal-badge urgent" if urgent else "animal-badge"
210
- breed = desc.get("breed_estimate", "raca desconhecida")
211
  color_coat = desc.get("primary_color", "")
212
- meta = f"{breed}{' Β· ' + color_coat if color_coat else ''}"
213
- count = a["sighting_count"]
214
  last_seen = a.get("last_seen_short", "")
215
  photo_url = a.get("last_photo_url") or ""
 
216
  if photo_url:
217
- thumb = f'<img src="{photo_url}" style="width:52px;height:52px;border-radius:10px;object-fit:cover;" onerror="this.parentElement.innerHTML=\'{em}\'">'
218
  else:
219
  thumb = em
220
- urgent_icon = '<i class="fa-solid fa-triangle-exclamation"></i> ' if urgent else ''
221
- cards.append(f'''
222
- <div class="animal-card">
223
  <div class="animal-thumb">{thumb}</div>
224
  <div class="animal-info">
225
- <div class="animal-name">{sp_pt} #{a["id"]}</div>
226
  <div class="animal-meta">{meta}</div>
227
- <div class="animal-meta" style="color:{color};">{urgent_icon}Visto {count}x Β· ultimo: {last_seen}</div>
228
  </div>
229
  <div class="{badge_cls}">{count}x</div>
230
  </div>''')
@@ -233,25 +265,145 @@ def build_animals_html():
233
  return f'<div class="section-header">🐾 {total_a} animais Β· {total_s} avistamentos</div>' + "".join(cards)
234
 
235
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
236
  def build_confirmation_html(animal_id, is_new, count, species, photo_url=""):
237
  em = "πŸ•" if species == "dog" else "🐈"
238
- sp_pt = "cao" if species == "dog" else "gato"
239
- title = f"Novo {sp_pt} registrado!" if is_new else f"{em} Animal reconhecido!"
240
- sub = "1o avistamento β€” obrigada por registrar!" if is_new else f"Este {sp_pt} ja foi avistado <b>{count}x</b> na regiao."
241
- photo_html = f'<img src="{photo_url}" style="width:100%;max-height:160px;object-fit:cover;border-radius:8px;margin-bottom:8px;" onerror="this.style.display=\'none\'">' if photo_url else ""
 
 
 
 
 
 
242
  return f'''
243
- <div id="status-card" class="ok">
244
- {photo_html}
245
- <div style="font-size:24px;margin-bottom:6px;color:#388C59;">βœ… {em}</div>
246
- <div style="font-weight:700;font-size:15px;">{title}</div>
247
- <div>{sub}</div>
248
- <div style="font-size:12px;margin-top:6px;color:#555;">ID #{animal_id} Β· Avistamento salvo</div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
249
  </div>'''
250
 
251
 
252
  def process_sighting(image, gps_json, notes, progress=gr.Progress()):
253
  if image is None:
254
- return gr.update(), '<div id="status-card" class="err">Tire uma foto do animal antes de registrar.</div>'
255
  progress(0.15, desc="Analisando imagem...")
256
  try:
257
  coords = json.loads(gps_json) if gps_json and gps_json.strip() else {}
@@ -287,9 +439,10 @@ def process_sighting(image, gps_json, notes, progress=gr.Progress()):
287
  return gr.update(value=build_map_html()), build_confirmation_html(animal_id, is_new, count, species, photo_url)
288
 
289
 
290
- with gr.Blocks(css=CSS, head=HEAD_HTML, theme=gr.themes.Base(), title="Animal Visto 🐾") as demo:
291
 
292
  gr.HTML('<div id="top-bar"><h1>🐾 Animal Visto</h1><small>Vinhedo, SP</small></div>')
 
293
 
294
  species_st = gr.State("all")
295
  timeframe_st = gr.State("all")
@@ -299,7 +452,7 @@ with gr.Blocks(css=CSS, head=HEAD_HTML, theme=gr.themes.Base(), title="Animal Vi
299
  with gr.Tab("πŸ—ΊοΈ Mapa"):
300
  with gr.Row(elem_classes="filter-row"):
301
  btn_all = gr.Button("Todos", elem_classes="filter-row")
302
- btn_dogs = gr.Button("πŸ• Caes", elem_classes="filter-row")
303
  btn_cats = gr.Button("🐈 Gatos", elem_classes="filter-row")
304
  btn_today = gr.Button("Hoje", elem_classes="filter-row secondary")
305
  btn_week = gr.Button("Esta semana", elem_classes="filter-row secondary")
@@ -307,12 +460,12 @@ with gr.Blocks(css=CSS, head=HEAD_HTML, theme=gr.themes.Base(), title="Animal Vi
307
 
308
  with gr.Tab("πŸ“· Registrar", elem_id="register-tab"):
309
  with gr.Column(elem_classes="form-card"):
310
- gr.HTML('<div class="reg-label"><i class="fa-solid fa-location-dot"></i> Passo 1: Localizacao</div>')
311
  gr.HTML(GPS_HTML)
312
- gps_coords = gr.Textbox(value="", elem_id="gps-coords", interactive=True, visible=False, label="gps-coords")
313
  gr.HTML('<div class="reg-label"><i class="fa-solid fa-camera"></i> Passo 2: Foto do Animal</div>')
314
- photo_input = gr.Image(label="", type="pil", sources=["upload", "webcam"], interactive=True, show_label=False, elem_id="photo-upload")
315
- gr.HTML('<div class="reg-label"><i class="fa-solid fa-file-signature"></i> Passo 3: Observacoes (opcional)</div>')
316
  notes_input = gr.Textbox(label="", placeholder="Ex.: parece ferido, tem coleira...", lines=2, max_lines=4, show_label=False)
317
  submit_btn = gr.Button("Registrar avistamento", variant="primary", elem_id="submit-btn")
318
  status_html = gr.HTML("")
@@ -321,6 +474,11 @@ with gr.Blocks(css=CSS, head=HEAD_HTML, theme=gr.themes.Base(), title="Animal Vi
321
  refresh_btn = gr.Button("Atualizar lista", size="sm", variant="secondary")
322
  animals_display = gr.HTML(build_animals_html())
323
 
 
 
 
 
 
324
  submit_btn.click(process_sighting, inputs=[photo_input, gps_coords, notes_input], outputs=[map_html, status_html])
325
  btn_all.click(lambda: (build_map_html("all","all"),"all","all"), outputs=[map_html, species_st, timeframe_st])
326
  btn_dogs.click(lambda t: (build_map_html("dog",t),"dog",t), inputs=[timeframe_st], outputs=[map_html, species_st, timeframe_st])
@@ -330,13 +488,21 @@ with gr.Blocks(css=CSS, head=HEAD_HTML, theme=gr.themes.Base(), title="Animal Vi
330
  refresh_btn.click(build_animals_html, outputs=[animals_display])
331
  demo.load(build_map_html, outputs=[map_html])
332
 
 
 
 
 
 
 
 
 
333
 
334
  if __name__ == "__main__":
335
  import gradio.blocks as _gb_mod
336
  _orig_gai = _gb_mod.Blocks.get_api_info
337
  def _safe_get_api_info(self):
338
  try: return _orig_gai(self)
339
- except Exception as _e: logging.warning(f"get_api_info: {_e}"); return {}
340
  _gb_mod.Blocks.get_api_info = _safe_get_api_info
341
  from core.database import DATA_DIR, PHOTOS_DIR
342
  PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
 
23
  ai = AnimalAI()
24
  matcher = AnimalMatcher()
25
 
26
+ C_GREEN = "#388C59"
27
+ C_RED = "#E53935"
28
+ C_ORANGE = "#FB8C00"
 
 
29
 
30
  HEAD_HTML = """
31
  <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css" crossorigin="anonymous">
32
  <script>
33
  (function() {
34
+ // ── GPS ────────────────────────────────────────────────────────────────────
35
+ function setGradioValue(id, val) {
36
  var attempts = 0;
37
+ function trySet() {
38
+ var el = document.getElementById(id);
39
+ if (!el) { if (attempts++ < 20) setTimeout(trySet, 300); return; }
40
+ var tb = el.querySelector('textarea') || el.querySelector('input[type="text"]');
41
+ if (!tb) { if (attempts++ < 20) setTimeout(trySet, 300); return; }
42
+ var proto = tb.tagName === 'TEXTAREA' ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
43
  var setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
44
+ setter.call(tb, val);
45
  tb.dispatchEvent(new Event('input', { bubbles: true }));
46
  tb.dispatchEvent(new Event('change', { bubbles: true }));
47
  }
48
+ trySet();
49
  }
50
 
51
  function requestGPS() {
 
57
  btn.disabled = true;
58
  btn.innerHTML = '<i class="fa-solid fa-spinner fa-spin"></i> Detectando...';
59
  icon.innerHTML = '<i class="fa-solid fa-spinner fa-spin" style="color:#388C59"></i>';
 
60
  navigator.geolocation.getCurrentPosition(
61
  function(pos) {
62
  var lat = parseFloat(pos.coords.latitude.toFixed(5));
63
  var lng = parseFloat(pos.coords.longitude.toFixed(5));
64
  icon.innerHTML = '<i class="fa-solid fa-check" style="color:#388C59"></i>';
65
+ text.innerHTML = '<strong>LocalizaΓ§Γ£o:</strong> ' + lat + ', ' + lng;
66
  btn.innerHTML = '<i class="fa-solid fa-rotate"></i> Atualizar';
67
  btn.disabled = false;
68
  btn.style.background = '#2d7a4a';
69
+ setGradioValue('gps-coords', JSON.stringify({ lat: lat, lng: lng }));
70
  },
71
  function(err) {
72
  icon.innerHTML = '<i class="fa-solid fa-triangle-exclamation" style="color:#E53935"></i>';
73
  btn.innerHTML = '<i class="fa-solid fa-location-dot"></i> Tentar novamente';
74
  btn.disabled = false;
75
  btn.style.background = '#E53935';
76
+ var msgs = { 1:'PermissΓ£o negada.', 2:'LocalizaΓ§Γ£o indisponΓ­vel.', 3:'Timeout GPS.' };
77
  text.textContent = msgs[err.code] || 'Erro: ' + err.message;
78
+ setGradioValue('gps-coords', JSON.stringify({}));
79
  },
80
  { enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
81
  );
82
  }
 
83
  window.animalVistoRequestGPS = requestGPS;
 
84
  document.addEventListener('click', function(e) {
85
+ var t = e.target.closest('.tab-nav button');
86
+ if (t && t.textContent.includes('Registrar')) setTimeout(requestGPS, 600);
87
  });
88
 
89
+ // ── CΓ’mera mobile ─────────────────────────────────────────────────────────
90
+ function patchCamera() {
91
  var el = document.getElementById('photo-upload');
92
+ if (!el) { setTimeout(patchCamera, 800); return; }
93
  var inp = el.querySelector('input[type=file]');
94
  if (inp) { inp.setAttribute('capture', 'environment'); inp.setAttribute('accept', 'image/*'); }
95
+ else setTimeout(patchCamera, 800);
96
  }
97
+ setTimeout(patchCamera, 1500);
98
+
99
+ // ── Ficha modal ───────────────────────────────────────────────────────────
100
+ window.openFicha = function(animalId) {
101
+ document.getElementById('ficha-overlay').style.display = 'flex';
102
+ setGradioValue('selected-animal-id', String(animalId));
103
+ };
104
+ window.closeFicha = function() {
105
+ document.getElementById('ficha-overlay').style.display = 'none';
106
+ };
107
  })();
108
  </script>
109
  """
 
128
  .reg-label i { color: #388C59; }
129
  #photo-upload { border: 2px dashed #cfcfcf !important; border-radius: 12px !important; background: #f9f9f9 !important; min-height: 180px; }
130
  #submit-btn { margin-top: 10px !important; width: 100% !important; background: #388C59 !important; border: none !important; border-radius: 12px !important; font-size: 16px !important; font-weight: 600 !important; padding: 14px !important; color: white !important; }
131
+ #status-card { margin-top: 16px; border-radius: 16px; font-size: 14px; line-height: 1.5; }
 
 
132
  #gps-coords { display: none !important; }
133
+ #selected-animal-id { display: none !important; }
134
  #animals-tab { padding: 0 0 80px; background: white; }
135
+ .animal-card { display: flex; align-items: center; gap: 12px; background: white; border-radius: 12px; padding: 14px 16px; margin: 8px 12px; box-shadow: 0 2px 8px rgba(0,0,0,.07); border: 1px solid #f0f0f0; cursor: pointer; transition: transform .1s; }
136
+ .animal-card:active { transform: scale(.98); }
137
+ .animal-thumb { width: 56px; height: 56px; border-radius: 12px; flex-shrink: 0; display: flex; align-items: center; justify-content: center; font-size: 28px; overflow: hidden; background: #eee; }
138
+ .animal-thumb img { width: 100%; height: 100%; object-fit: cover; }
139
  .animal-info { flex: 1; min-width: 0; }
140
  .animal-name { font-weight: 600; font-size: 14px; color: #212121; }
141
  .animal-meta { font-size: 12px; color: #888; margin-top: 2px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
142
  .animal-badge { background: #D9EBD9; color: #388C59; border-radius: 20px; padding: 3px 9px; font-size: 12px; font-weight: 700; flex-shrink: 0; }
143
  .animal-badge.urgent { background: #ffebee; color: #E53935; }
144
  .section-header { padding: 14px 16px 6px; font-size: 13px; font-weight: 600; color: #888; text-transform: uppercase; letter-spacing: .5px; }
145
+ /* Ficha overlay */
146
+ #ficha-overlay { display: none; position: fixed; inset: 0; background: rgba(0,0,0,.5); z-index: 1000; align-items: flex-end; justify-content: center; }
147
+ #ficha-sheet { background: white; width: 100%; max-width: 480px; max-height: 90vh; border-radius: 20px 20px 0 0; overflow-y: auto; padding-bottom: 32px; }
148
+ #ficha-sheet .ficha-header { display: flex; align-items: center; gap: 12px; padding: 16px 16px 12px; border-bottom: 1px solid #f0f0f0; position: sticky; top: 0; background: white; z-index: 10; }
149
+ #ficha-sheet .ficha-header h2 { margin: 0; font-size: 17px; font-weight: 700; flex: 1; }
150
+ #ficha-sheet .ficha-header small { font-size: 12px; color: #888; display: block; }
151
+ .ficha-close { background: #f5f5f5; border: none; border-radius: 50%; width: 32px; height: 32px; font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center; }
152
+ .photo-strip { display: flex; gap: 8px; padding: 12px 16px; overflow-x: auto; scrollbar-width: none; }
153
+ .photo-strip-item { flex-shrink: 0; text-align: center; position: relative; }
154
+ .photo-strip-item img { width: 72px; height: 72px; object-fit: cover; border-radius: 10px; border: 2px solid #eee; }
155
+ .photo-strip-item.novo img { border-color: #388C59; }
156
+ .photo-strip-item .novo-badge { position: absolute; top: -6px; right: -6px; background: #388C59; color: white; font-size: 9px; font-weight: 700; padding: 2px 5px; border-radius: 8px; }
157
+ .photo-strip-item .photo-date { font-size: 10px; color: #888; margin-top: 4px; }
158
+ .ficha-section { margin: 0 16px 16px; }
159
+ .ficha-section h3 { font-size: 13px; font-weight: 700; color: #212121; margin: 0 0 10px; display: flex; align-items: center; gap: 6px; }
160
+ .id-table { width: 100%; border-collapse: collapse; }
161
+ .id-table td { padding: 7px 0; font-size: 13px; border-bottom: 1px solid #f5f5f5; }
162
+ .id-table td:first-child { color: #888; width: 40%; }
163
+ .id-table td:last-child { color: #212121; font-weight: 500; }
164
+ .marks-card { background: #fff8e1; border-radius: 10px; padding: 10px 12px; font-size: 13px; color: #795548; border-left: 3px solid #FFC107; }
165
+ .trajectory { display: flex; align-items: center; gap: 0; padding: 8px 0; position: relative; }
166
+ .traj-dot { width: 12px; height: 12px; border-radius: 50%; flex-shrink: 0; }
167
+ .traj-line { flex: 1; height: 2px; background: #ddd; }
168
+ .traj-label { font-size: 10px; color: #888; text-align: center; margin-top: 4px; }
169
+ .adopt-btn { display: block; width: calc(100% - 32px); margin: 8px 16px; padding: 14px; background: #D9EBD9; border: none; border-radius: 12px; color: #388C59; font-size: 15px; font-weight: 600; cursor: pointer; text-align: center; }
170
+ .adopt-btn:hover { background: #c8e6c9; }
171
  """
172
 
173
  GPS_HTML = """
174
  <div id="gps-box" style="display:flex;align-items:center;gap:10px;background:#D9EBD9;border-radius:10px;padding:12px 14px;font-size:13px;color:#2e7d32;font-weight:500;border:1px solid #c8e6c9;">
175
  <span id="gps-icon" style="font-size:18px"><i class="fa-solid fa-location-crosshairs"></i></span>
176
+ <span id="gps-text" style="flex:1">Toque para detectar localizaΓ§Γ£o</span>
177
  <button id="gps-btn" onclick="window.animalVistoRequestGPS && window.animalVistoRequestGPS()"
178
  style="background:#388C59;color:white;border:none;padding:6px 14px;border-radius:6px;font-size:12px;font-weight:600;cursor:pointer;white-space:nowrap;">
179
  <i class="fa-solid fa-location-dot"></i> Localizar
 
181
  </div>
182
  """
183
 
184
+ FICHA_OVERLAY_HTML = """
185
+ <div id="ficha-overlay" onclick="if(event.target===this)window.closeFicha()">
186
+ <div id="ficha-sheet">
187
+ <div id="ficha-content" style="min-height:200px;display:flex;align-items:center;justify-content:center;color:#aaa;">
188
+ <i class="fa-solid fa-spinner fa-spin" style="font-size:24px"></i>
189
+ </div>
190
+ </div>
191
+ </div>
192
+ """
193
+
194
 
195
  def build_map_html(species="all", timeframe="all"):
196
  import base64 as _b64
197
  data = db.get_map_data(species, timeframe)
 
198
  data_b64 = _b64.b64encode(json.dumps(data, ensure_ascii=False).encode()).decode()
 
199
  inner = (
200
  '<!DOCTYPE html><html><head>'
201
  '<meta charset="utf-8"/>'
202
  '<meta name="viewport" content="width=device-width,initial-scale=1"/>'
203
  '<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css"/>'
204
+ '<style>body{margin:0;padding:0;}#map{height:100vh;width:100%;}</style></head><body>'
 
 
205
  '<div id="map"></div>'
206
  '<script src="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js"></script>'
207
  '<script>'
208
  'var animals=JSON.parse(atob("' + data_b64 + '"));'
209
  'var map=L.map("map").setView([-23.0316,-46.9785],13);'
210
  'L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png",{attribution:"OSM",maxZoom:19}).addTo(map);'
211
+ 'if(!animals.length){var info=L.control({position:"topright"});info.onAdd=function(){var d=L.DomUtil.create("div");d.style="background:white;padding:8px 12px;border-radius:8px;font-size:13px;color:#888;";d.innerHTML="Nenhum avistamento ainda";return d;};info.addTo(map);}'
 
 
 
 
212
  'animals.forEach(function(a){'
213
  ' var color=a.days_since>30?"#E53935":a.count>1?"#FB8C00":"#388C59";'
214
  ' var em=a.species==="dog"?"D":"G";'
 
216
  ' var ico=L.divIcon({html:"<div style=\'position:relative;background:"+color+";width:38px;height:38px;border-radius:50%;display:flex;align-items:center;justify-content:center;font-size:16px;font-weight:700;color:white;box-shadow:0 2px 8px rgba(0,0,0,.3);border:2.5px solid white;\'>"+em+badge+"</div>",className:"",iconSize:[38,38],iconAnchor:[19,19],popupAnchor:[0,-22]});'
217
  ' var sp=a.species==="dog"?"Cao":"Gato";'
218
  ' var urg=a.days_since>30?"<br><small style=\'color:#E53935\'>Nao visto ha "+a.days_since+" dias</small>":"";'
219
+ ' L.marker([a.lat,a.lng],{icon:ico}).addTo(map).bindPopup("<b>"+sp+" #"+a.id+"</b>"+(a.desc?"<br><small>"+a.desc+"</small>":"")+"<br><small>"+a.count+"x - "+a.last_seen+"</small>"+urg,{maxWidth:220});'
 
220
  '});'
221
  '</script></body></html>'
222
  )
 
223
  srcdoc = inner.replace('&', '&amp;').replace('"', '&quot;')
224
  return f'<iframe srcdoc="{srcdoc}" style="width:100%;height:500px;border:none;"></iframe>'
225
 
 
227
  def build_animals_html():
228
  animals = db.get_recent_animals(limit=30)
229
  if not animals:
230
+ return '<div style="padding:40px 16px;text-align:center;color:#aaa;"><div style="font-size:48px">🐾</div><div style="font-size:15px;font-weight:500;margin-top:12px;">Nenhum animal registrado ainda</div><div style="font-size:13px;margin-top:6px;">VÑ para Registrar e tire a primeira foto!</div></div>'
231
  cards = []
232
  for a in animals:
233
  try:
234
  desc = json.loads(a.get("description") or "{}")
235
  except Exception:
236
  desc = {}
237
+ is_dog = a["species"] == "dog"
238
+ em = "πŸ•" if is_dog else "🐈"
239
+ sp_pt = "CΓ£o" if is_dog else "Gato"
240
+ urgent = a.get("days_since", 0) > 30
241
+ color = C_RED if urgent else C_GREEN
242
  badge_cls = "animal-badge urgent" if urgent else "animal-badge"
243
+ breed = desc.get("breed_estimate", "raΓ§a desconhecida")
244
  color_coat = desc.get("primary_color", "")
245
+ meta = f"{breed}{' Β· ' + color_coat if color_coat else ''}"
246
+ count = a["sighting_count"]
247
  last_seen = a.get("last_seen_short", "")
248
  photo_url = a.get("last_photo_url") or ""
249
+ urgent_icon = '<i class="fa-solid fa-triangle-exclamation"></i> ' if urgent else ""
250
  if photo_url:
251
+ thumb = f'<img src="{photo_url}" style="width:56px;height:56px;border-radius:12px;object-fit:cover;" onerror="this.parentElement.innerHTML=\'{em}\'">'
252
  else:
253
  thumb = em
254
+ cards.append(f'''<div class="animal-card" onclick="window.openFicha({a['id']})">
 
 
255
  <div class="animal-thumb">{thumb}</div>
256
  <div class="animal-info">
257
+ <div class="animal-name">{sp_pt} #{a['id']}</div>
258
  <div class="animal-meta">{meta}</div>
259
+ <div class="animal-meta" style="color:{color};">{urgent_icon}Visto {count}x Β· ΓΊltimo: {last_seen}</div>
260
  </div>
261
  <div class="{badge_cls}">{count}x</div>
262
  </div>''')
 
265
  return f'<div class="section-header">🐾 {total_a} animais Β· {total_s} avistamentos</div>' + "".join(cards)
266
 
267
 
268
+ def build_ficha_html(animal_id_str: str) -> str:
269
+ try:
270
+ animal_id = int(animal_id_str.strip())
271
+ except Exception:
272
+ return ""
273
+ detail = db.get_animal_detail(animal_id)
274
+ if not detail:
275
+ return ""
276
+ animal = detail["animal"]
277
+ sightings = detail["sightings"]
278
+ try:
279
+ desc = json.loads(animal.get("description") or "{}")
280
+ except Exception:
281
+ desc = {}
282
+
283
+ is_dog = animal["species"] == "dog"
284
+ em = "πŸ•" if is_dog else "🐈"
285
+ sp_pt = "Cachorro" if is_dog else "Gato"
286
+ count = animal["sighting_count"]
287
+ breed = desc.get("breed_estimate", "Vira-lata")
288
+ size_map = {"small": "Pequeno", "medium": "MΓ©dio", "large": "Grande"}
289
+ size_pt = size_map.get(desc.get("size", ""), desc.get("size", "β€”"))
290
+ colors = desc.get("primary_color", "β€”")
291
+ if desc.get("secondary_colors"):
292
+ colors += " com " + ", ".join(desc["secondary_colors"])
293
+ condition_map = {"healthy": "Aparentemente saudΓ‘vel", "thin": "Magro", "injured": "Ferido"}
294
+ condition_pt = condition_map.get(desc.get("condition", ""), "β€”")
295
+ marks = desc.get("distinctive_marks", [])
296
+
297
+ # Tira foto strip
298
+ photos_html = ""
299
+ for i, s in enumerate(sightings[:6]):
300
+ date_str = (s.get("created_at") or "")[:10]
301
+ try:
302
+ from datetime import date
303
+ d = date.fromisoformat(date_str)
304
+ date_label = f"{d.day:02d}/{d.month:02d}"
305
+ except Exception:
306
+ date_label = date_str[-5:] if date_str else "β€”"
307
+ is_novo = (i == 0)
308
+ novo_badge = '<span class="novo-badge">Novo</span>' if is_novo else ""
309
+ novo_cls = "novo" if is_novo else ""
310
+ if s["photo_url"]:
311
+ img_tag = f'<img src="{s["photo_url"]}" onerror="this.src=\'\'"/>'
312
+ else:
313
+ img_tag = f'<div style="width:72px;height:72px;border-radius:10px;background:#f0f0f0;display:flex;align-items:center;justify-content:center;font-size:28px;border:2px solid #eee;">{em}</div>'
314
+ photos_html += f'<div class="photo-strip-item {novo_cls}">{novo_badge}{img_tag}<div class="photo-date">{date_label}</div></div>'
315
+
316
+ # TrajetΓ³ria
317
+ traj_html = ""
318
+ if len(sightings) > 1:
319
+ colors_dot = ["#388C59", "#FB8C00", "#E53935"]
320
+ items = list(reversed(sightings[:5]))
321
+ for i, s in enumerate(items):
322
+ date_str = (s.get("created_at") or "")[:10]
323
+ try:
324
+ from datetime import date
325
+ d = date.fromisoformat(date_str)
326
+ lbl = f"{d.day:02d}/{d.month:02d}"
327
+ except Exception:
328
+ lbl = date_str[-5:] if date_str else ""
329
+ dot_color = colors_dot[min(i, 2)]
330
+ traj_html += f'<div style="display:flex;flex-direction:column;align-items:center;gap:4px;">'
331
+ if i > 0:
332
+ traj_html += f'<div style="flex:1;height:2px;background:#ddd;width:40px;margin-top:6px;"></div>'
333
+ traj_html += f'<div style="width:12px;height:12px;border-radius:50%;background:{dot_color};"></div>'
334
+ traj_html += f'<div style="font-size:10px;color:#888;">{lbl}</div></div>'
335
+
336
+ # Marcas
337
+ marks_html = ""
338
+ if marks:
339
+ marks_list = " Β· ".join(marks)
340
+ marks_html = f'<div class="ficha-section"><h3>✨ Características marcantes</h3><div class="marks-card">{marks_list}</div></div>'
341
+
342
+ ficha = f'''
343
+ <div class="ficha-header">
344
+ <button class="ficha-close" onclick="window.closeFicha()"><i class="fa-solid fa-xmark"></i></button>
345
+ <div>
346
+ <h2>Ficha do animal</h2>
347
+ <small>{count} avistamento{'s' if count != 1 else ''} na regiΓ£o</small>
348
+ </div>
349
+ </div>
350
+ <div class="photo-strip">{photos_html}</div>
351
+ <div class="ficha-section">
352
+ <h3>🐾 Identificação</h3>
353
+ <table class="id-table">
354
+ <tr><td>EspΓ©cie</td><td>{sp_pt}</td></tr>
355
+ <tr><td>RaΓ§a</td><td>{breed}</td></tr>
356
+ <tr><td>Porte</td><td>{size_pt}</td></tr>
357
+ <tr><td>Cor</td><td>{colors}</td></tr>
358
+ <tr><td>CondiΓ§Γ£o</td><td>{condition_pt}</td></tr>
359
+ </table>
360
+ </div>
361
+ {marks_html}
362
+ {'<div class="ficha-section"><h3>πŸ“ TrajetΓ³ria observada</h3><div style="display:flex;align-items:flex-start;gap:0;">' + traj_html + '</div></div>' if traj_html else ''}
363
+ <button class="adopt-btn">🏠 Quero adotar β€” entrar em contato</button>
364
+ '''
365
+ return f'<div id="ficha-content">{ficha}</div>'
366
+
367
+
368
  def build_confirmation_html(animal_id, is_new, count, species, photo_url=""):
369
  em = "πŸ•" if species == "dog" else "🐈"
370
+ sp_pt = "Cachorro" if species == "dog" else "Gato"
371
+ try:
372
+ desc_raw = (db.get_animal(animal_id) or {}).get("description") or "{}"
373
+ desc = json.loads(desc_raw)
374
+ breed = desc.get("breed_estimate", "Vira-lata")
375
+ except Exception:
376
+ breed = "Vira-lata"
377
+ title = "Avistamento registrado!" if is_new else f"{em} Animal reconhecido!"
378
+ sub = "Obrigado por ajudar os animais da sua região 🐾"
379
+ ord_pt = {1:"1Β°",2:"2Β°",3:"3Β°",4:"4Β°"}.get(count, f"{count}Β°")
380
  return f'''
381
+ <div id="status-card" style="background:white;border-radius:16px;overflow:hidden;box-shadow:0 4px 20px rgba(0,0,0,0.1);">
382
+ <div style="background:#D9EBD9;padding:32px 16px 24px;text-align:center;">
383
+ <div style="width:80px;height:80px;border-radius:50%;background:#388C59;display:inline-flex;align-items:center;justify-content:center;font-size:36px;position:relative;margin-bottom:12px;">
384
+ {em}
385
+ <div style="position:absolute;bottom:-4px;right:-4px;width:26px;height:26px;border-radius:50%;background:white;display:flex;align-items:center;justify-content:center;box-shadow:0 2px 6px rgba(0,0,0,.2);">
386
+ <i class="fa-solid fa-check" style="color:#388C59;font-size:12px;"></i>
387
+ </div>
388
+ </div>
389
+ <div style="font-weight:700;font-size:20px;color:#212121;margin-bottom:4px;">{title}</div>
390
+ <div style="font-size:13px;color:#555;">{sub}</div>
391
+ </div>
392
+ <div style="padding:16px;">
393
+ <div style="background:#f8f9fa;border-radius:12px;padding:12px 14px;font-size:13px;margin-bottom:16px;">
394
+ <div style="display:flex;gap:10px;padding:6px 0;border-bottom:1px solid #eee;"><span style="color:#888;width:90px;flex-shrink:0;">Animal</span><span style="font-weight:500;">{sp_pt} Β· {breed}</span></div>
395
+ <div style="display:flex;gap:10px;padding:6px 0;border-bottom:1px solid #eee;"><span style="color:#888;width:90px;flex-shrink:0;">Registrado</span><span style="font-weight:500;">Agora hΓ‘ pouco</span></div>
396
+ <div style="display:flex;gap:10px;padding:6px 0;"><span style="color:#888;width:90px;flex-shrink:0;">Na regiΓ£o</span><span style="font-weight:500;">{ord_pt} avistamento</span></div>
397
+ </div>
398
+ <button onclick="document.querySelector('.tab-nav button').click()" style="width:100%;padding:13px;background:#388C59;color:white;border:none;border-radius:12px;font-size:15px;font-weight:600;cursor:pointer;margin-bottom:8px;">Ver no mapa</button>
399
+ <button onclick="document.querySelectorAll('.tab-nav button')[1].click()" style="width:100%;padding:13px;background:transparent;color:#388C59;border:2px solid #388C59;border-radius:12px;font-size:15px;font-weight:600;cursor:pointer;">Registrar outro animal</button>
400
+ </div>
401
  </div>'''
402
 
403
 
404
  def process_sighting(image, gps_json, notes, progress=gr.Progress()):
405
  if image is None:
406
+ return gr.update(), '<div style="padding:16px;background:#ffebee;border-radius:12px;color:#b71c1c;border-left:4px solid #E53935;">Tire uma foto do animal antes de registrar.</div>'
407
  progress(0.15, desc="Analisando imagem...")
408
  try:
409
  coords = json.loads(gps_json) if gps_json and gps_json.strip() else {}
 
439
  return gr.update(value=build_map_html()), build_confirmation_html(animal_id, is_new, count, species, photo_url)
440
 
441
 
442
+ with gr.Blocks(css=CSS, head=HEAD_HTML, theme=gr.themes.Base(), title="Animal Visto") as demo:
443
 
444
  gr.HTML('<div id="top-bar"><h1>🐾 Animal Visto</h1><small>Vinhedo, SP</small></div>')
445
+ gr.HTML(FICHA_OVERLAY_HTML)
446
 
447
  species_st = gr.State("all")
448
  timeframe_st = gr.State("all")
 
452
  with gr.Tab("πŸ—ΊοΈ Mapa"):
453
  with gr.Row(elem_classes="filter-row"):
454
  btn_all = gr.Button("Todos", elem_classes="filter-row")
455
+ btn_dogs = gr.Button("πŸ• CΓ£es", elem_classes="filter-row")
456
  btn_cats = gr.Button("🐈 Gatos", elem_classes="filter-row")
457
  btn_today = gr.Button("Hoje", elem_classes="filter-row secondary")
458
  btn_week = gr.Button("Esta semana", elem_classes="filter-row secondary")
 
460
 
461
  with gr.Tab("πŸ“· Registrar", elem_id="register-tab"):
462
  with gr.Column(elem_classes="form-card"):
463
+ gr.HTML('<div class="reg-label"><i class="fa-solid fa-location-dot"></i> Passo 1: LocalizaΓ§Γ£o</div>')
464
  gr.HTML(GPS_HTML)
465
+ gps_coords = gr.Textbox(value="", elem_id="gps-coords", interactive=True, visible=False, label="gps")
466
  gr.HTML('<div class="reg-label"><i class="fa-solid fa-camera"></i> Passo 2: Foto do Animal</div>')
467
+ photo_input = gr.Image(label="", type="pil", sources=["upload","webcam"], interactive=True, show_label=False, elem_id="photo-upload")
468
+ gr.HTML('<div class="reg-label"><i class="fa-solid fa-file-signature"></i> Passo 3: ObservaΓ§Γ΅es (opcional)</div>')
469
  notes_input = gr.Textbox(label="", placeholder="Ex.: parece ferido, tem coleira...", lines=2, max_lines=4, show_label=False)
470
  submit_btn = gr.Button("Registrar avistamento", variant="primary", elem_id="submit-btn")
471
  status_html = gr.HTML("")
 
474
  refresh_btn = gr.Button("Atualizar lista", size="sm", variant="secondary")
475
  animals_display = gr.HTML(build_animals_html())
476
 
477
+ # Ficha modal: campo oculto recebe animal_id via JS β†’ carrega ficha
478
+ selected_animal_id = gr.Textbox(value="", elem_id="selected-animal-id", visible=False, interactive=True, label="sel")
479
+ ficha_html = gr.HTML("", elem_id="ficha-content-gradio")
480
+
481
+ # Eventos
482
  submit_btn.click(process_sighting, inputs=[photo_input, gps_coords, notes_input], outputs=[map_html, status_html])
483
  btn_all.click(lambda: (build_map_html("all","all"),"all","all"), outputs=[map_html, species_st, timeframe_st])
484
  btn_dogs.click(lambda t: (build_map_html("dog",t),"dog",t), inputs=[timeframe_st], outputs=[map_html, species_st, timeframe_st])
 
488
  refresh_btn.click(build_animals_html, outputs=[animals_display])
489
  demo.load(build_map_html, outputs=[map_html])
490
 
491
+ # Ficha: quando animal selecionado muda β†’ carrega HTML e injeta no overlay
492
+ def load_ficha_and_inject(animal_id_str):
493
+ html = build_ficha_html(animal_id_str)
494
+ # Injeta via JS no overlay
495
+ js = f"<script>var fc=document.getElementById('ficha-content');if(fc){{fc.outerHTML=`{html.replace('`','\'')}`;}}</script>"
496
+ return js
497
+ selected_animal_id.change(load_ficha_and_inject, inputs=[selected_animal_id], outputs=[ficha_html])
498
+
499
 
500
  if __name__ == "__main__":
501
  import gradio.blocks as _gb_mod
502
  _orig_gai = _gb_mod.Blocks.get_api_info
503
  def _safe_get_api_info(self):
504
  try: return _orig_gai(self)
505
+ except Exception as e: logging.warning(f"get_api_info: {e}"); return {}
506
  _gb_mod.Blocks.get_api_info = _safe_get_api_info
507
  from core.database import DATA_DIR, PHOTOS_DIR
508
  PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
core/database.py CHANGED
@@ -158,6 +158,16 @@ class Database:
158
  ).fetchall()
159
  return [dict(r) for r in rows]
160
 
 
 
 
 
 
 
 
 
 
 
161
  # ─── Map data ────────────────────────────────────────────────────────────
162
 
163
  def get_map_data(self, species: str = "all", timeframe: str = "all") -> list:
 
158
  ).fetchall()
159
  return [dict(r) for r in rows]
160
 
161
+ def get_animal_detail(self, animal_id: int) -> Optional[dict]:
162
+ """Retorna animal + todos os avistamentos com URLs de foto."""
163
+ animal = self.get_animal(animal_id)
164
+ if not animal:
165
+ return None
166
+ sightings = self.get_animal_sightings(animal_id)
167
+ for s in sightings:
168
+ s["photo_url"] = self.photo_url(s.get("photo_path")) or ""
169
+ return {"animal": animal, "sightings": sightings}
170
+
171
  # ─── Map data ────────────────────────────────────────────────────────────
172
 
173
  def get_map_data(self, species: str = "all", timeframe: str = "all") -> list: