Spaces:
Running
Running
sara.mesquita commited on
Commit Β·
0cfcf78
1
Parent(s): ad56f8a
adjusts ia
Browse files- app.py +245 -79
- core/database.py +10 -0
app.py
CHANGED
|
@@ -23,30 +23,29 @@ db = Database()
|
|
| 23 |
ai = AnimalAI()
|
| 24 |
matcher = AnimalMatcher()
|
| 25 |
|
| 26 |
-
C_GREEN
|
| 27 |
-
|
| 28 |
-
|
| 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 |
-
|
|
|
|
| 37 |
var attempts = 0;
|
| 38 |
-
function
|
| 39 |
-
var
|
| 40 |
-
if (!
|
| 41 |
-
var tb =
|
| 42 |
-
if (!tb) { if (attempts++ < 20) setTimeout(
|
| 43 |
-
var proto =
|
| 44 |
var setter = Object.getOwnPropertyDescriptor(proto, 'value').set;
|
| 45 |
-
setter.call(tb,
|
| 46 |
tb.dispatchEvent(new Event('input', { bubbles: true }));
|
| 47 |
tb.dispatchEvent(new Event('change', { bubbles: true }));
|
| 48 |
}
|
| 49 |
-
|
| 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>
|
| 68 |
btn.innerHTML = '<i class="fa-solid fa-rotate"></i> Atualizar';
|
| 69 |
btn.disabled = false;
|
| 70 |
btn.style.background = '#2d7a4a';
|
| 71 |
-
|
| 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:'
|
| 79 |
text.textContent = msgs[err.code] || 'Erro: ' + err.message;
|
| 80 |
-
|
| 81 |
},
|
| 82 |
{ enableHighAccuracy: true, timeout: 15000, maximumAge: 0 }
|
| 83 |
);
|
| 84 |
}
|
| 85 |
-
|
| 86 |
window.animalVistoRequestGPS = requestGPS;
|
| 87 |
-
|
| 88 |
document.addEventListener('click', function(e) {
|
| 89 |
-
var
|
| 90 |
-
if (
|
| 91 |
});
|
| 92 |
|
| 93 |
-
|
|
|
|
| 94 |
var el = document.getElementById('photo-upload');
|
| 95 |
-
if (!el) { setTimeout(
|
| 96 |
var inp = el.querySelector('input[type=file]');
|
| 97 |
if (inp) { inp.setAttribute('capture', 'environment'); inp.setAttribute('accept', 'image/*'); }
|
| 98 |
-
else setTimeout(
|
| 99 |
}
|
| 100 |
-
setTimeout(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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:
|
| 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-
|
|
|
|
|
|
|
| 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
|
| 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 |
-
'
|
| 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('&', '&').replace('"', '"')
|
| 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
|
| 205 |
-
em
|
| 206 |
-
sp_pt
|
| 207 |
-
urgent
|
| 208 |
-
color
|
| 209 |
badge_cls = "animal-badge urgent" if urgent else "animal-badge"
|
| 210 |
-
breed
|
| 211 |
color_coat = desc.get("primary_color", "")
|
| 212 |
-
meta
|
| 213 |
-
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:
|
| 218 |
else:
|
| 219 |
thumb = em
|
| 220 |
-
|
| 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[
|
| 226 |
<div class="animal-meta">{meta}</div>
|
| 227 |
-
<div class="animal-meta" style="color:{color};">{urgent_icon}Visto {count}x Β·
|
| 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 = "
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 242 |
return f'''
|
| 243 |
-
<div id="status-card"
|
| 244 |
-
|
| 245 |
-
|
| 246 |
-
|
| 247 |
-
|
| 248 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
| 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
|
| 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("π
|
| 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:
|
| 311 |
gr.HTML(GPS_HTML)
|
| 312 |
-
gps_coords = gr.Textbox(value="", elem_id="gps-coords", interactive=True, visible=False, label="gps
|
| 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",
|
| 315 |
-
gr.HTML('<div class="reg-label"><i class="fa-solid fa-file-signature"></i> Passo 3:
|
| 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
|
| 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('&', '&').replace('"', '"')
|
| 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:
|