SucoCafe commited on
Commit
a6d27a8
·
1 Parent(s): d00cf04

Atualizando app.py com chaves do Supabase e Roboflow

Browse files
Files changed (5) hide show
  1. app.py +137 -112
  2. requirements.txt +1 -0
  3. static/script.js +1034 -1
  4. static/style.css +75 -0
  5. templates/index.html +213 -1
app.py CHANGED
@@ -113,7 +113,88 @@ text_descriptions_longclip = {
113
 
114
  @app.route('/')
115
  def home():
116
- return render_template('index.html')
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
117
 
118
  @app.route('/predict', methods=['POST'])
119
  def predict():
@@ -125,132 +206,76 @@ def predict():
125
  return jsonify({'error': 'Arquivo vazio selecionado'}), 400
126
 
127
  try:
128
- # A imagem é recebida do site
129
  image_path = os.path.join(app.config['UPLOAD_FOLDER'], "temp_leaf.jpg")
130
  file.save(image_path)
131
 
132
- # --- LÓGICA DE LIMPEZA DA PASTA ---
133
- if os.path.exists(output_dir):
134
- for filename in os.listdir(output_dir):
135
- file_path = os.path.join(output_dir, filename)
136
- try:
137
- if os.path.isfile(file_path) or os.path.islink(file_path):
138
- os.unlink(file_path)
139
- elif os.path.isdir(file_path):
140
- shutil.rmtree(file_path)
141
- except Exception as e:
142
- print(f'Falha ao deletar {file_path}. Motivo: {e}')
143
- else:
144
- os.makedirs(output_dir)
145
-
146
- # --- 1. DETECÇÃO (ROBOFLOW) ---
147
- image = Image.open(image_path)
148
-
149
- print("Iniciando detecção com Roboflow...")
150
  model_roboflow = get_model("bracol-validado-region-detect-oupfv/1")
151
- predictions = model_roboflow.infer(image, confidence=0.65)[0]
152
- detections = sv.Detections.from_inference(predictions)
153
-
154
- # Salvar recortes com padding condicional
155
- print(f"Salvando novos recortes com padding condicional em '{output_dir}'...")
156
-
157
- # Obter dimensões da imagem original
158
- img_width, img_height = image.size
159
- padding_percentage = 0.20 # 20% de padding
160
- min_dim_for_padding = 100 # Dimensão mínima para aplicar padding
161
 
162
- for i, (xyxy, _, _, _, _, _) in enumerate(detections):
163
- x_min_orig, y_min_orig, x_max_orig, y_max_orig = xyxy
 
 
164
 
165
- # Calcular largura e altura da caixa delimitadora original
166
- bbox_width_orig = x_max_orig - x_min_orig
167
- bbox_height_orig = y_max_orig - y_min_orig
168
 
169
- # Inicializar as coordenadas finais com as originais
170
- final_x_min, final_y_min, final_x_max, final_y_max = x_min_orig, y_min_orig, x_max_orig, y_max_orig
171
 
172
- # Verificar se o recorte é pequeno o suficiente para aplicar o padding
173
- if bbox_width_orig < min_dim_for_padding or bbox_height_orig < min_dim_for_padding:
174
- # Calcular o valor do padding
175
- pad_x = bbox_width_orig * padding_percentage
176
- pad_y = bbox_height_orig * padding_percentage
 
 
 
 
 
 
 
 
 
 
177
 
178
- # Aplicar padding e garantir que as coordenadas não excedam os limites da imagem
179
- final_x_min = max(0, x_min_orig - pad_x)
180
- final_y_min = max(0, y_min_orig - pad_y)
181
- final_x_max = min(img_width, x_max_orig + pad_x)
182
- final_y_max = min(img_height, y_max_orig + pad_y)
183
 
184
- # Converter para inteiros, pois image.crop espera inteiros
185
- final_x_min, final_y_min, final_x_max, final_y_max = int(final_x_min), int(final_y_min), int(final_x_max), int(final_y_max)
 
186
 
187
- cropped_image = image.crop((final_x_min, final_y_min, final_x_max, final_y_max))
 
 
 
188
 
189
- filename = f"recorte_{i}.jpg"
190
- cropped_image.save(os.path.join(output_dir, filename))
 
 
191
 
192
- # --- 2. CLASSIFICAÇÃO (LONGCLIP) ---
193
- checkpoint_abs_path = os.path.join(long_clip_path, "checkpoints", "longclip-L.pt")
194
- long_model, long_preprocess = load_longclip_model(checkpoint_abs_path, device)
195
- text_features, class_names = get_text_features(long_model, text_descriptions_longclip, device)
 
 
 
 
 
 
 
 
 
 
196
 
197
- results_list = []
198
- image_urls = []
199
-
200
- print("\nIniciando classificação dos recortes com LongCLIP...")
201
- for file_name in os.listdir(output_dir):
202
- if file_name.startswith("recorte_") and file_name.endswith(".jpg"):
203
- img_path = os.path.join(output_dir, file_name)
204
- img_input = Image.open(img_path)
205
-
206
- image_input = long_preprocess(img_input).unsqueeze(0).to(device)
207
-
208
- with torch.no_grad():
209
- image_features = long_model.encode_image(image_input)
210
- image_features /= image_features.norm(dim=-1, keepdim=True)
211
-
212
- # Cálculo de similaridade de cosseno (LongCLIP)
213
- logits = (100.0 * image_features @ text_features.T)
214
- probs = logits.softmax(dim=-1).cpu().numpy()[0]
215
-
216
- top_idx = np.argmax(probs)
217
- predicted_class = class_names[top_idx]
218
- top_prob = float(probs[top_idx])
219
-
220
- results_list.append(predicted_class)
221
-
222
- image_urls.append({
223
- "url": f"/static/outputs/{file_name}",
224
- "classe": predicted_class,
225
- "probabilidade": top_prob
226
- })
227
- print(f"Arquivo {file_name}: {predicted_class} ({probs[top_idx]*100:.2f}%)")
228
-
229
- # --- 3. RELATÓRIO FINAL ---
230
- print("\n" + "="*30)
231
- print("RELATÓRIO DE SAÚDE DO CAFEEIRO")
232
- print("="*30)
233
- contagem = Counter(results_list)
234
-
235
- for doenca, qtd in contagem.items():
236
- print(f"{doenca}: {qtd} ocorrência(s)")
237
-
238
- if not results_list:
239
- print("Nenhuma detecção encontrada para análise.")
240
- return jsonify({
241
- "contagem": {},
242
- "mais_frequente": "Nenhuma detecção encontrada",
243
- "total": 0,
244
- "imagens": []
245
- })
246
-
247
- mais_frequente = contagem.most_common(1)[0][0]
248
-
249
  return jsonify({
250
- "contagem": dict(contagem),
251
- "mais_frequente": mais_frequente,
252
- "total": sum(contagem.values()),
253
- "imagens": image_urls
254
  })
255
 
256
  except Exception as e:
 
113
 
114
  @app.route('/')
115
  def home():
116
+ supabase_url = os.environ.get('SUPABASE_URL')
117
+ supabase_key = os.environ.get('SUPABASE_ANON_KEY')
118
+ return render_template('index.html', supabase_url=supabase_url, supabase_key=supabase_key)
119
+
120
+ def process_leaf_image(image_path, model_roboflow, long_model, long_preprocess, text_features, class_names, device):
121
+ # LÓGICA DE LIMPEZA DA PASTA
122
+ if os.path.exists(output_dir):
123
+ for filename in os.listdir(output_dir):
124
+ file_path = os.path.join(output_dir, filename)
125
+ try:
126
+ if os.path.isfile(file_path) or os.path.islink(file_path):
127
+ os.unlink(file_path)
128
+ elif os.path.isdir(file_path):
129
+ shutil.rmtree(file_path)
130
+ except Exception as e:
131
+ print(f'Falha ao deletar {file_path}. Motivo: {e}')
132
+ else:
133
+ os.makedirs(output_dir)
134
+
135
+ # 1. DETECÇÃO (ROBOFLOW)
136
+ image = Image.open(image_path)
137
+ predictions = model_roboflow.infer(image, confidence=0.65)[0]
138
+ detections = sv.Detections.from_inference(predictions)
139
+
140
+ img_width, img_height = image.size
141
+ padding_percentage = 0.20
142
+ min_dim_for_padding = 100
143
+
144
+ for i, xyxy in enumerate(detections.xyxy):
145
+ x_min_orig, y_min_orig, x_max_orig, y_max_orig = xyxy
146
+ bbox_width_orig = x_max_orig - x_min_orig
147
+ bbox_height_orig = y_max_orig - y_min_orig
148
+ final_x_min, final_y_min, final_x_max, final_y_max = x_min_orig, y_min_orig, x_max_orig, y_max_orig
149
+
150
+ if bbox_width_orig < min_dim_for_padding or bbox_height_orig < min_dim_for_padding:
151
+ pad_x = bbox_width_orig * padding_percentage
152
+ pad_y = bbox_height_orig * padding_percentage
153
+ final_x_min = max(0, x_min_orig - pad_x)
154
+ final_y_min = max(0, y_min_orig - pad_y)
155
+ final_x_max = min(img_width, x_max_orig + pad_x)
156
+ final_y_max = min(img_height, y_max_orig + pad_y)
157
+
158
+ final_x_min, final_y_min, final_x_max, final_y_max = int(final_x_min), int(final_y_min), int(final_x_max), int(final_y_max)
159
+ cropped_image = image.crop((final_x_min, final_y_min, final_x_max, final_y_max))
160
+ cropped_image.save(os.path.join(output_dir, f"recorte_{i}.jpg"))
161
+
162
+ # 2. CLASSIFICAÇÃO (LONGCLIP)
163
+ results_list = []
164
+ image_urls = []
165
+
166
+ for file_name in os.listdir(output_dir):
167
+ if file_name.startswith("recorte_") and file_name.endswith(".jpg"):
168
+ img_path = os.path.join(output_dir, file_name)
169
+ img_input = Image.open(img_path)
170
+ image_input = long_preprocess(img_input).unsqueeze(0).to(device)
171
+
172
+ with torch.no_grad():
173
+ image_features = long_model.encode_image(image_input)
174
+ image_features /= image_features.norm(dim=-1, keepdim=True)
175
+ logits = (100.0 * image_features @ text_features.T)
176
+ probs = logits.softmax(dim=-1).cpu().numpy()[0]
177
+
178
+ top_idx = np.argmax(probs)
179
+ predicted_class = class_names[top_idx]
180
+ top_prob = float(probs[top_idx])
181
+
182
+ results_list.append(predicted_class)
183
+ image_urls.append({
184
+ "url": f"/static/outputs/{file_name}",
185
+ "classe": predicted_class,
186
+ "probabilidade": top_prob
187
+ })
188
+
189
+ contagem = Counter(results_list)
190
+ mais_frequente = contagem.most_common(1)[0][0] if results_list else "Nenhuma detecção encontrada"
191
+
192
+ return {
193
+ "contagem": dict(contagem),
194
+ "mais_frequente": mais_frequente,
195
+ "total": sum(contagem.values()),
196
+ "imagens": image_urls
197
+ }
198
 
199
  @app.route('/predict', methods=['POST'])
200
  def predict():
 
206
  return jsonify({'error': 'Arquivo vazio selecionado'}), 400
207
 
208
  try:
 
209
  image_path = os.path.join(app.config['UPLOAD_FOLDER'], "temp_leaf.jpg")
210
  file.save(image_path)
211
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
212
  model_roboflow = get_model("bracol-validado-region-detect-oupfv/1")
213
+ checkpoint_abs_path = os.path.join(long_clip_path, "checkpoints", "longclip-L.pt")
214
+ long_model, long_preprocess = load_longclip_model(checkpoint_abs_path, device)
215
+ text_features, class_names = get_text_features(long_model, text_descriptions_longclip, device)
 
 
 
 
 
 
 
216
 
217
+ result = process_leaf_image(
218
+ image_path, model_roboflow, long_model, long_preprocess,
219
+ text_features, class_names, device
220
+ )
221
 
222
+ return jsonify(result)
 
 
223
 
224
+ except Exception as e:
225
+ return jsonify({'error': str(e)}), 500
226
 
227
+ @app.route('/predict_batch', methods=['POST'])
228
+ def predict_batch():
229
+ if 'files' not in request.files:
230
+ return jsonify({'error': 'Nenhum arquivo enviado'}), 400
231
+
232
+ files = request.files.getlist('files')
233
+ if not files or files[0].filename == '':
234
+ return jsonify({'error': 'Nenhum arquivo selecionado'}), 400
235
+
236
+ try:
237
+ # Carregar modelos uma única vez para o lote inteiro
238
+ model_roboflow = get_model("bracol-validado-region-detect-oupfv/1")
239
+ checkpoint_abs_path = os.path.join(long_clip_path, "checkpoints", "longclip-L.pt")
240
+ long_model, long_preprocess = load_longclip_model(checkpoint_abs_path, device)
241
+ text_features, class_names = get_text_features(long_model, text_descriptions_longclip, device)
242
 
243
+ batch_results = []
 
 
 
 
244
 
245
+ for idx, file in enumerate(files):
246
+ image_path = os.path.join(app.config['UPLOAD_FOLDER'], f"temp_leaf_batch_{idx}.jpg")
247
+ file.save(image_path)
248
 
249
+ result = process_leaf_image(
250
+ image_path, model_roboflow, long_model, long_preprocess,
251
+ text_features, class_names, device
252
+ )
253
 
254
+ batch_results.append({
255
+ "filename": file.filename,
256
+ "mais_frequente": result["mais_frequente"]
257
+ })
258
 
259
+ # Consolidar a contagem da doença principal de cada folha do lote
260
+ doencas_principais = [res["mais_frequente"] for res in batch_results if res["mais_frequente"] != "Nenhuma detecção encontrada"]
261
+ contagem_geral = Counter(doencas_principais)
262
+
263
+ # Calcular porcentagens e garantir que todas as classes (doenças) apareçam no relatório
264
+ total_validas = len(doencas_principais)
265
+ estatisticas = {}
266
+ for doenca in class_names:
267
+ qtd = contagem_geral.get(doenca, 0)
268
+ pct = (qtd / total_validas * 100) if total_validas > 0 else 0
269
+ estatisticas[doenca] = {
270
+ "quantidade": qtd,
271
+ "porcentagem": round(pct, 1)
272
+ }
273
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
274
  return jsonify({
275
+ "estatisticas": estatisticas,
276
+ "detalhes": batch_results,
277
+ "total_folhas": len(files),
278
+ "folhas_com_deteccao": total_validas
279
  })
280
 
281
  except Exception as e:
requirements.txt CHANGED
@@ -10,3 +10,4 @@ gunicorn
10
  ftfy
11
  regex
12
  tqdm
 
 
10
  ftfy
11
  regex
12
  tqdm
13
+ supabase
static/script.js CHANGED
@@ -16,6 +16,92 @@ document.addEventListener('DOMContentLoaded', () => {
16
  let selectedFile = null;
17
  let chartInstance = null;
18
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19
  // Theming Colors for the Chart
20
  const chartColors = [
21
  '#2f855a', // Green
@@ -121,7 +207,48 @@ document.addEventListener('DOMContentLoaded', () => {
121
  // Update Text
122
  mainDisease.textContent = data.mais_frequente;
123
 
124
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
125
 
126
  // Render Chart
127
  renderChart(data.contagem);
@@ -204,4 +331,910 @@ document.addEventListener('DOMContentLoaded', () => {
204
  }
205
  });
206
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
207
  });
 
16
  let selectedFile = null;
17
  let chartInstance = null;
18
 
19
+ // Tabs
20
+ const tabIndividual = document.getElementById('tab-individual');
21
+ const tabBatch = document.getElementById('tab-batch');
22
+ const wrapperIndividual = document.getElementById('wrapper-individual');
23
+ const wrapperBatch = document.getElementById('wrapper-batch');
24
+ const tabAppCafe = document.getElementById('tab-appcafe');
25
+ const wrapperAppCafe = document.getElementById('wrapper-appcafe');
26
+
27
+ // Supabase Auth Elements
28
+ const appcafeLoginForm = document.getElementById('appcafe-login-form');
29
+ const appcafeEmailInput = document.getElementById('appcafe-email');
30
+ const appcafePasswordInput = document.getElementById('appcafe-password');
31
+ const appcafeLoginBtn = document.getElementById('appcafe-login-btn');
32
+ const appcafeBtnText = appcafeLoginBtn?.querySelector('.btn-text');
33
+ const appcafeLoader = document.getElementById('appcafe-loader');
34
+ const appcafeError = document.getElementById('appcafe-error');
35
+ const appcafeLoginContainer = document.getElementById('appcafe-login-container');
36
+ const appcafeLoggedIn = document.getElementById('appcafe-logged-in');
37
+ const appcafeLogoutBtn = document.getElementById('appcafe-logout-btn');
38
+ const appcafePromo = document.querySelector('.appcafe-promo');
39
+
40
+ // Dashboard Buttons
41
+ const btnOlharFotos = document.getElementById('btn-olhar-fotos');
42
+ const btnMapaCalor = document.getElementById('btn-mapa-calor');
43
+ const btnDashboardIndividual = document.getElementById('btn-dashboard-individual');
44
+ const btnDashboardLote = document.getElementById('btn-dashboard-lote');
45
+
46
+ // Gallery Modal
47
+ const galleryModal = document.getElementById('gallery-modal');
48
+ const closeGalleryBtn = document.getElementById('close-gallery-btn');
49
+ const galleryContainer = document.getElementById('gallery-container');
50
+ const galleryLoader = document.getElementById('gallery-loader');
51
+
52
+ // Gallery Filters
53
+ const filterDesc = document.getElementById('filter-desc');
54
+ const filterDateStart = document.getElementById('filter-date-start');
55
+ const filterDateEnd = document.getElementById('filter-date-end');
56
+ const clearFiltersBtn = document.getElementById('clear-filters-btn');
57
+ const galleryFilters = document.getElementById('gallery-filters');
58
+ let currentGalleryPictures = [];
59
+ let selectedGalleryPictures = new Map();
60
+
61
+ // Gallery Actions
62
+ const galleryActionBar = document.getElementById('gallery-action-bar');
63
+ const selectedCountText = document.getElementById('selected-count');
64
+ const btnCancelSelection = document.getElementById('btn-cancel-selection');
65
+ const btnAnalyzeSelected = document.getElementById('btn-analyze-selected');
66
+ const btnDeleteSelected = document.getElementById('btn-delete-selected');
67
+ const btnSelectAll = document.getElementById('select-all-btn');
68
+ const analyzeActionText = document.getElementById('analyze-action-text');
69
+ const analyzeActionLoader = document.getElementById('analyze-action-loader');
70
+ const analyzeActionHint = document.getElementById('analyze-action-hint');
71
+
72
+ // Heatmap
73
+ let isHeatmapMode = false;
74
+ let diseaseDictionary = {}; // id_disease -> name
75
+ const heatmapModal = document.getElementById('heatmap-modal');
76
+ const closeHeatmapBtn = document.getElementById('close-heatmap-btn');
77
+ const heatmapDiseaseFilter = document.getElementById('heatmap-disease-filter');
78
+ const heatmapStyleFilter = document.getElementById('heatmap-style-filter');
79
+ let leafletMapInstance = null;
80
+ let currentHeatLayer = null;
81
+
82
+ let supabaseClient = null;
83
+ if (window.supabase && window.SUPABASE_URL && window.SUPABASE_ANON_KEY) {
84
+ supabaseClient = window.supabase.createClient(window.SUPABASE_URL, window.SUPABASE_ANON_KEY);
85
+ }
86
+
87
+ // Batch UI Elements
88
+ const batchUploadArea = document.getElementById('batch-upload-area');
89
+ const batchFileInput = document.getElementById('batch-file-input');
90
+ const batchImagePreview = document.getElementById('batch-image-preview');
91
+ const batchUploadContent = document.getElementById('batch-upload-content');
92
+ const batchAnalyzeBtn = document.getElementById('batch-analyze-btn');
93
+ const batchBtnText = document.getElementById('batch-btn-text');
94
+ const batchLoader = document.getElementById('batch-loader');
95
+
96
+ const batchResultsPlaceholder = document.getElementById('batch-results-placeholder');
97
+ const batchResultsContent = document.getElementById('batch-results-content');
98
+ const batchTotalLeaves = document.getElementById('batch-total-leaves');
99
+ const batchMainDisease = document.getElementById('batch-main-disease');
100
+ const batchChartCanvas = document.getElementById('batchResultsChart');
101
+
102
+ let selectedBatchFiles = [];
103
+ let batchChartInstance = null;
104
+
105
  // Theming Colors for the Chart
106
  const chartColors = [
107
  '#2f855a', // Green
 
207
  // Update Text
208
  mainDisease.textContent = data.mais_frequente;
209
 
210
+ // --- Save Result Logic ---
211
+ const saveContainer = document.getElementById('save-result-container-individual');
212
+ const saveBtn = document.getElementById('btn-save-result-individual');
213
+ const saveMsg = document.getElementById('save-result-msg-individual');
214
+
215
+ if (selectedFile && selectedFile.supabasePictureId && data.mais_frequente !== "Nenhuma detecção encontrada" && supabaseClient) {
216
+ saveContainer.classList.remove('hidden');
217
+ saveBtn.style.display = 'inline-flex';
218
+ saveMsg.style.display = 'none';
219
+
220
+ const newSaveBtn = saveBtn.cloneNode(true);
221
+ saveBtn.parentNode.replaceChild(newSaveBtn, saveBtn);
222
+
223
+ newSaveBtn.addEventListener('click', async () => {
224
+ newSaveBtn.disabled = true;
225
+ newSaveBtn.innerHTML = '<i class="ph-duotone ph-spinner" style="animation: spin 1s linear infinite;"></i> Salvando...';
226
+
227
+ try {
228
+ const diseaseName = data.mais_frequente;
229
+ // Find disease ID
230
+ const { data: diseaseData, error: dError } = await supabaseClient.from('diseases').select('id').eq('name', diseaseName).single();
231
+ if (dError || !diseaseData) throw new Error("Doença não encontrada no banco de dados.");
232
+
233
+ // Update picture
234
+ const { error: pError } = await supabaseClient.from('pictures').update({ id_disease: diseaseData.id }).eq('id', selectedFile.supabasePictureId);
235
+ if (pError) throw new Error("Erro ao atualizar a foto.");
236
+
237
+ newSaveBtn.style.display = 'none';
238
+ saveMsg.innerHTML = '<i class="ph-fill ph-check-circle"></i> Resultado salvo com sucesso no banco de dados!';
239
+ saveMsg.style.color = "#2f855a";
240
+ saveMsg.style.display = 'block';
241
+ } catch (e) {
242
+ newSaveBtn.disabled = false;
243
+ newSaveBtn.innerHTML = '<i class="ph-bold ph-floppy-disk"></i> Tentar Novamente';
244
+ saveMsg.textContent = e.message;
245
+ saveMsg.style.color = "#e53e3e";
246
+ saveMsg.style.display = 'block';
247
+ }
248
+ });
249
+ } else {
250
+ if (saveContainer) saveContainer.classList.add('hidden');
251
+ }
252
 
253
  // Render Chart
254
  renderChart(data.contagem);
 
331
  }
332
  });
333
  }
334
+
335
+ // --- BATCH LOGIC ---
336
+
337
+ // Tab Switching
338
+ tabIndividual.addEventListener('click', () => {
339
+ tabIndividual.classList.add('active');
340
+ tabBatch.classList.remove('active');
341
+ if (tabAppCafe) tabAppCafe.classList.remove('active');
342
+
343
+ wrapperIndividual.classList.remove('hidden');
344
+ wrapperBatch.classList.add('hidden');
345
+ if (wrapperAppCafe) wrapperAppCafe.classList.add('hidden');
346
+ });
347
+
348
+ tabBatch.addEventListener('click', () => {
349
+ tabBatch.classList.add('active');
350
+ tabIndividual.classList.remove('active');
351
+ if (tabAppCafe) tabAppCafe.classList.remove('active');
352
+
353
+ wrapperBatch.classList.remove('hidden');
354
+ wrapperIndividual.classList.add('hidden');
355
+ if (wrapperAppCafe) wrapperAppCafe.classList.add('hidden');
356
+ });
357
+
358
+ if (tabAppCafe) {
359
+ tabAppCafe.addEventListener('click', () => {
360
+ tabAppCafe.classList.add('active');
361
+ tabIndividual.classList.remove('active');
362
+ tabBatch.classList.remove('active');
363
+
364
+ wrapperAppCafe.classList.remove('hidden');
365
+ wrapperIndividual.classList.add('hidden');
366
+ wrapperBatch.classList.add('hidden');
367
+
368
+ checkSession();
369
+ });
370
+ }
371
+
372
+ // Drag and Drop for Batch
373
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
374
+ batchUploadArea.addEventListener(eventName, preventDefaults, false);
375
+ });
376
+
377
+ ['dragenter', 'dragover'].forEach(eventName => {
378
+ batchUploadArea.addEventListener(eventName, () => batchUploadArea.classList.add('dragover'), false);
379
+ });
380
+
381
+ ['dragleave', 'drop'].forEach(eventName => {
382
+ batchUploadArea.addEventListener(eventName, () => batchUploadArea.classList.remove('dragover'), false);
383
+ });
384
+
385
+ batchUploadArea.addEventListener('drop', handleBatchDrop, false);
386
+ batchUploadArea.addEventListener('click', () => batchFileInput.click());
387
+ batchFileInput.addEventListener('change', handleBatchFileSelect);
388
+
389
+ function handleBatchDrop(e) {
390
+ const dt = e.dataTransfer;
391
+ handleBatchFiles(dt.files);
392
+ }
393
+
394
+ function handleBatchFileSelect(e) {
395
+ handleBatchFiles(e.target.files);
396
+ }
397
+
398
+ function handleBatchFiles(files) {
399
+ const validFiles = Array.from(files).filter(file => file.type.startsWith('image/'));
400
+
401
+ if (validFiles.length > 0) {
402
+ selectedBatchFiles = validFiles;
403
+
404
+ batchImagePreview.innerHTML = '';
405
+ validFiles.forEach(file => {
406
+ const reader = new FileReader();
407
+ reader.onload = (e) => {
408
+ const img = document.createElement('img');
409
+ img.src = e.target.result;
410
+ batchImagePreview.appendChild(img);
411
+ };
412
+ reader.readAsDataURL(file);
413
+ });
414
+
415
+ batchImagePreview.classList.remove('hidden');
416
+ batchUploadContent.classList.add('hidden');
417
+ batchAnalyzeBtn.disabled = false;
418
+ } else {
419
+ alert('Por favor, selecione arquivos de imagem válidos.');
420
+ }
421
+ }
422
+
423
+ batchAnalyzeBtn.addEventListener('click', async () => {
424
+ if (selectedBatchFiles.length === 0) return;
425
+
426
+ // UI Loading State
427
+ batchAnalyzeBtn.disabled = true;
428
+ batchBtnText.innerHTML = '<i class="ph-bold ph-scan"></i> Analisando Lote...';
429
+ batchLoader.classList.remove('hidden');
430
+ batchResultsPlaceholder.querySelector('p').textContent = `Processando ${selectedBatchFiles.length} imagens (isso vai levar um tempo)...`;
431
+ batchResultsContent.classList.add('hidden');
432
+ batchResultsPlaceholder.classList.remove('hidden');
433
+
434
+ const formData = new FormData();
435
+ selectedBatchFiles.forEach(file => {
436
+ formData.append('files', file);
437
+ });
438
+
439
+ try {
440
+ const response = await fetch('/predict_batch', {
441
+ method: 'POST',
442
+ body: formData
443
+ });
444
+
445
+ if (!response.ok) {
446
+ const errData = await response.json();
447
+ throw new Error(errData.error || 'Erro na análise em lote.');
448
+ }
449
+
450
+ const data = await response.json();
451
+ updateBatchResults(data);
452
+ } catch (error) {
453
+ alert(error.message);
454
+ batchResultsPlaceholder.querySelector('p').textContent = 'Falha na análise em lote. Tente novamente.';
455
+ } finally {
456
+ batchAnalyzeBtn.disabled = false;
457
+ batchBtnText.innerHTML = '<i class="ph-bold ph-scan"></i> Analisar Lote';
458
+ batchLoader.classList.add('hidden');
459
+ }
460
+ });
461
+
462
+ function updateBatchResults(data) {
463
+ batchResultsPlaceholder.classList.add('hidden');
464
+ batchResultsContent.classList.remove('hidden');
465
+
466
+ // --- Save Result Logic para Lote ---
467
+ const saveContainer = document.getElementById('save-result-container-batch');
468
+ const saveBtn = document.getElementById('btn-save-result-batch');
469
+ const saveMsg = document.getElementById('save-result-msg-batch');
470
+
471
+ const hasSupabaseFiles = selectedBatchFiles && selectedBatchFiles.some(f => f.supabasePictureId);
472
+
473
+ if (hasSupabaseFiles && data.detalhes && data.detalhes.length > 0 && supabaseClient) {
474
+ saveContainer.classList.remove('hidden');
475
+ saveBtn.style.display = 'inline-flex';
476
+ saveMsg.style.display = 'none';
477
+
478
+ const newSaveBtn = saveBtn.cloneNode(true);
479
+ saveBtn.parentNode.replaceChild(newSaveBtn, saveBtn);
480
+
481
+ newSaveBtn.addEventListener('click', async () => {
482
+ newSaveBtn.disabled = true;
483
+ newSaveBtn.innerHTML = '<i class="ph-duotone ph-spinner" style="animation: spin 1s linear infinite;"></i> Salvando...';
484
+
485
+ try {
486
+ const { data: allDiseases, error: dError } = await supabaseClient.from('diseases').select('id, name');
487
+ if (dError) throw new Error("Erro ao buscar doenças.");
488
+
489
+ const diseaseMap = {};
490
+ allDiseases.forEach(d => { diseaseMap[d.name] = d.id; });
491
+
492
+ let savedCount = 0;
493
+ for (const result of data.detalhes) {
494
+ if (result.mais_frequente === "Nenhuma detecção encontrada") continue;
495
+
496
+ const diseaseId = diseaseMap[result.mais_frequente];
497
+ if (!diseaseId) continue;
498
+
499
+ const matchingFile = selectedBatchFiles.find(f => f.name === result.filename);
500
+ if (matchingFile && matchingFile.supabasePictureId) {
501
+ await supabaseClient.from('pictures').update({ id_disease: diseaseId }).eq('id', matchingFile.supabasePictureId);
502
+ savedCount++;
503
+ }
504
+ }
505
+
506
+ newSaveBtn.style.display = 'none';
507
+ saveMsg.innerHTML = `<i class="ph-fill ph-check-circle"></i> ${savedCount} resultado(s) salvo(s) com sucesso!`;
508
+ saveMsg.style.color = "#2f855a";
509
+ saveMsg.style.display = 'block';
510
+ } catch (e) {
511
+ newSaveBtn.disabled = false;
512
+ newSaveBtn.innerHTML = '<i class="ph-bold ph-floppy-disk-back"></i> Tentar Novamente';
513
+ saveMsg.textContent = e.message;
514
+ saveMsg.style.color = "#e53e3e";
515
+ saveMsg.style.display = 'block';
516
+ }
517
+ });
518
+ } else {
519
+ if (saveContainer) saveContainer.classList.add('hidden');
520
+ }
521
+
522
+ batchTotalLeaves.textContent = data.total_folhas;
523
+
524
+ if (data.folhas_com_deteccao > 0) {
525
+ let mainDisease = "Nenhuma detectada";
526
+ let maxQtd = -1;
527
+ let maxPct = 0;
528
+
529
+ for (const [doenca, stats] of Object.entries(data.estatisticas)) {
530
+ if (stats.quantidade > maxQtd) {
531
+ maxQtd = stats.quantidade;
532
+ maxPct = stats.porcentagem;
533
+ mainDisease = doenca;
534
+ }
535
+ }
536
+ batchMainDisease.textContent = `${mainDisease} (${maxPct}%)`;
537
+ } else {
538
+ batchMainDisease.textContent = "Nenhuma detectada";
539
+ }
540
+
541
+ renderBatchChart(data.estatisticas);
542
+ }
543
+
544
+ function renderBatchChart(estatisticas) {
545
+ const labels = [];
546
+ const dataValues = [];
547
+
548
+ // Adiciona a porcentagem direto no label (Ex: Ferrugem (60.0%)) e usa o valor para plotar
549
+ for (const [doenca, stats] of Object.entries(estatisticas)) {
550
+ labels.push(`${doenca} (${stats.porcentagem}%)`);
551
+ dataValues.push(stats.porcentagem);
552
+ }
553
+
554
+ if (batchChartInstance) {
555
+ batchChartInstance.destroy();
556
+ }
557
+
558
+ batchChartInstance = new Chart(batchChartCanvas, {
559
+ type: 'doughnut',
560
+ data: {
561
+ labels: labels,
562
+ datasets: [{
563
+ data: dataValues,
564
+ backgroundColor: chartColors,
565
+ borderWidth: 0,
566
+ hoverOffset: 4
567
+ }]
568
+ },
569
+ options: {
570
+ responsive: true,
571
+ maintainAspectRatio: false,
572
+ plugins: {
573
+ legend: {
574
+ position: 'right',
575
+ labels: {
576
+ color: '#4a5568',
577
+ font: { family: "'Outfit', sans-serif", size: 10 },
578
+ padding: 10,
579
+ boxWidth: 12
580
+ }
581
+ },
582
+ tooltip: {
583
+ callbacks: {
584
+ label: function(context) {
585
+ let label = context.label || '';
586
+ return ' ' + label;
587
+ }
588
+ },
589
+ backgroundColor: 'rgba(255, 255, 255, 0.95)',
590
+ titleColor: '#2d3748',
591
+ bodyColor: '#2d3748',
592
+ borderColor: 'rgba(47, 133, 90, 0.2)',
593
+ borderWidth: 1,
594
+ padding: 10,
595
+ titleFont: { family: "'Outfit', sans-serif", size: 12, weight: 'bold' },
596
+ bodyFont: { family: "'Outfit', sans-serif", size: 12 }
597
+ }
598
+ },
599
+ cutout: '70%'
600
+ }
601
+ });
602
+ }
603
+
604
+ // --- SUPABASE AUTH LOGIC ---
605
+ async function checkSession() {
606
+ if (!supabaseClient) return;
607
+ const { data, error } = await supabaseClient.auth.getSession();
608
+ if (data && data.session) {
609
+ showLoggedInState(data.session.user);
610
+ } else {
611
+ showLoginState();
612
+ }
613
+ }
614
+
615
+ function showLoggedInState(user) {
616
+ if (appcafeLoginContainer) appcafeLoginContainer.classList.add('hidden');
617
+ if (appcafePromo) appcafePromo.classList.add('hidden');
618
+ if (appcafeLoggedIn) appcafeLoggedIn.classList.remove('hidden');
619
+
620
+ const welcomeText = document.getElementById('welcome-user-text');
621
+ if (welcomeText && user) {
622
+ const name = user.user_metadata?.display_name || user.email.split('@')[0];
623
+ welcomeText.textContent = `Bem-vindo(a), ${name}`;
624
+ }
625
+ }
626
+
627
+ function showLoginState() {
628
+ if (appcafeLoginContainer) appcafeLoginContainer.classList.remove('hidden');
629
+ if (appcafePromo) appcafePromo.classList.remove('hidden');
630
+ if (appcafeLoggedIn) appcafeLoggedIn.classList.add('hidden');
631
+ if (appcafeError) appcafeError.style.display = 'none';
632
+ if (appcafeEmailInput) appcafeEmailInput.value = '';
633
+ if (appcafePasswordInput) appcafePasswordInput.value = '';
634
+ }
635
+
636
+ if (appcafeLoginForm) {
637
+ appcafeLoginForm.addEventListener('submit', async (e) => {
638
+ e.preventDefault();
639
+ if (!supabaseClient) return;
640
+
641
+ const email = appcafeEmailInput.value;
642
+ const password = appcafePasswordInput.value;
643
+
644
+ // UI Loading State
645
+ appcafeLoginBtn.disabled = true;
646
+ if (appcafeBtnText) appcafeBtnText.textContent = 'Entrando...';
647
+ if (appcafeLoader) appcafeLoader.classList.remove('hidden');
648
+ if (appcafeError) appcafeError.style.display = 'none';
649
+
650
+ const { data, error } = await supabaseClient.auth.signInWithPassword({
651
+ email: email,
652
+ password: password,
653
+ });
654
+
655
+ appcafeLoginBtn.disabled = false;
656
+ if (appcafeBtnText) appcafeBtnText.textContent = 'Entrar';
657
+ if (appcafeLoader) appcafeLoader.classList.add('hidden');
658
+
659
+ if (error) {
660
+ if (appcafeError) {
661
+ appcafeError.textContent = error.message === 'Invalid login credentials' ? 'E-mail ou senha incorretos.' : error.message;
662
+ appcafeError.style.display = 'block';
663
+ }
664
+ } else {
665
+ showLoggedInState();
666
+ }
667
+ });
668
+ }
669
+
670
+ const appcafeForgotPassword = document.getElementById('appcafe-forgot-password');
671
+ if (appcafeForgotPassword) {
672
+ appcafeForgotPassword.addEventListener('click', async (e) => {
673
+ e.preventDefault();
674
+ if (!supabaseClient) return;
675
+ const email = appcafeEmailInput.value;
676
+ if (!email) {
677
+ alert('Por favor, preencha o campo de e-mail para recuperar a senha.');
678
+ return;
679
+ }
680
+
681
+ try {
682
+ appcafeLoginBtn.disabled = true;
683
+ if (appcafeBtnText) appcafeBtnText.textContent = 'Enviando...';
684
+ const { error } = await supabaseClient.auth.resetPasswordForEmail(email);
685
+ if (error) throw error;
686
+ alert('E-mail de recuperação enviado! Verifique sua caixa de entrada.');
687
+ } catch (err) {
688
+ alert('Erro ao enviar e-mail: ' + err.message);
689
+ } finally {
690
+ appcafeLoginBtn.disabled = false;
691
+ if (appcafeBtnText) appcafeBtnText.textContent = 'Entrar';
692
+ }
693
+ });
694
+ }
695
+
696
+ if (appcafeLogoutBtn) {
697
+ appcafeLogoutBtn.addEventListener('click', async () => {
698
+ if (!supabaseClient) return;
699
+ await supabaseClient.auth.signOut();
700
+ showLoginState();
701
+ });
702
+ }
703
+
704
+ // Inicializa verificando a sessão caso inicie na tab (embora comece escondida)
705
+ checkSession();
706
+
707
+ // --- FILTER LOGIC ---
708
+ function renderGalleryCards(picturesList) {
709
+ galleryContainer.innerHTML = '';
710
+ if (picturesList && picturesList.length > 0) {
711
+ picturesList.forEach(pic => {
712
+ const isSelected = selectedGalleryPictures.has(pic.picture);
713
+ const card = document.createElement('div');
714
+ card.style = `background: #1e293b; border-radius: 12px; overflow: hidden; box-shadow: 0 10px 15px -3px rgba(0,0,0,0.5); display: flex; flex-direction: column; border: 2px solid ${isSelected ? 'var(--accent-primary)' : 'rgba(255,255,255,0.1)'}; transition: all 0.2s; cursor: pointer; position: relative; transform: ${isSelected ? 'scale(0.98)' : 'scale(1)'};`;
715
+
716
+ // Overlay for selection checkmark
717
+ const checkOverlay = document.createElement('div');
718
+ checkOverlay.style = `position: absolute; top: 10px; right: 10px; width: 30px; height: 30px; border-radius: 50%; background: ${isSelected ? 'var(--accent-primary)' : 'rgba(0,0,0,0.5)'}; border: 2px solid white; display: flex; align-items: center; justify-content: center; z-index: 5; transition: background 0.2s;`;
719
+ checkOverlay.innerHTML = `<i class="ph-bold ph-check" style="color: white; font-size: 1.2rem; display: ${isSelected ? 'block' : 'none'};"></i>`;
720
+
721
+ card.onmouseover = () => { if (!selectedGalleryPictures.has(pic.picture)) card.style.transform = "translateY(-5px)"; };
722
+ card.onmouseout = () => { if (!selectedGalleryPictures.has(pic.picture)) card.style.transform = "translateY(0)"; };
723
+
724
+ card.addEventListener('click', () => {
725
+ if (selectedGalleryPictures.has(pic.picture)) {
726
+ selectedGalleryPictures.delete(pic.picture);
727
+ card.style.border = "2px solid rgba(255,255,255,0.1)";
728
+ card.style.transform = "translateY(0)";
729
+ checkOverlay.style.background = "rgba(0,0,0,0.5)";
730
+ checkOverlay.querySelector('i').style.display = "none";
731
+ } else {
732
+ selectedGalleryPictures.set(pic.picture, pic.id);
733
+ card.style.border = "2px solid var(--accent-primary)";
734
+ card.style.transform = "scale(0.98)";
735
+ checkOverlay.style.background = "var(--accent-primary)";
736
+ checkOverlay.querySelector('i').style.display = "block";
737
+ }
738
+ updateGalleryActionBar();
739
+ });
740
+
741
+ const imgContainer = document.createElement('div');
742
+ imgContainer.style = "width: 100%; height: 220px; background: #0f172a; overflow: hidden; position: relative;";
743
+
744
+ const img = document.createElement('img');
745
+ img.src = pic.picture;
746
+ img.alt = "Foto do Café";
747
+ img.style = "width: 100%; height: 100%; object-fit: cover;";
748
+
749
+ const info = document.createElement('div');
750
+ info.style = "padding: 20px; display: flex; flex-direction: column; flex-grow: 1; justify-content: space-between;";
751
+
752
+ const dateText = document.createElement('p');
753
+ const dataFormatada = new Date(pic.data).toLocaleDateString('pt-BR', { day: '2-digit', month: 'short', year: 'numeric', hour: '2-digit', minute: '2-digit' });
754
+ dateText.innerHTML = `<i class="ph-bold ph-calendar-blank"></i> ${dataFormatada}`;
755
+ dateText.style = "margin: 0 0 8px 0; font-size: 0.85rem; color: #94a3b8; display: flex; align-items: center; gap: 6px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.5px;";
756
+
757
+ const descText = document.createElement('p');
758
+ descText.innerHTML = pic.description ? `"${pic.description}"` : '<em>Sem descrição fornecida.</em>';
759
+ descText.style = "margin: 0 0 10px 0; font-size: 1rem; color: #f8fafc; font-style: italic; line-height: 1.5;";
760
+
761
+ let diseaseHtml = '';
762
+ if (pic.id_disease && diseaseDictionary[pic.id_disease]) {
763
+ diseaseHtml = `<span style="background: rgba(47, 133, 90, 0.2); color: #48bb78; padding: 4px 8px; border-radius: 4px; font-size: 0.8rem; font-weight: bold; border: 1px solid rgba(47, 133, 90, 0.5); display: inline-block;"><i class="ph-bold ph-virus"></i> ${diseaseDictionary[pic.id_disease]}</span>`;
764
+ }
765
+
766
+ imgContainer.appendChild(img);
767
+ card.appendChild(checkOverlay);
768
+ info.appendChild(dateText);
769
+ info.appendChild(descText);
770
+ if (diseaseHtml) {
771
+ const dWrap = document.createElement('div');
772
+ dWrap.innerHTML = diseaseHtml;
773
+ info.appendChild(dWrap);
774
+ }
775
+ card.appendChild(imgContainer);
776
+ card.appendChild(info);
777
+
778
+ galleryContainer.appendChild(card);
779
+ });
780
+ } else {
781
+ galleryContainer.innerHTML = '<div style="grid-column: 1 / -1; text-align: center; padding: 60px 20px; background: rgba(255,255,255,0.05); border-radius: 12px; border: 1px dashed rgba(255,255,255,0.2);"><i class="ph-duotone ph-image-broken" style="font-size: 4rem; color: #64748b; margin-bottom: 15px;"></i><p style="color: #cbd5e1; font-size: 1.2rem; margin: 0;">Nenhuma foto encontrada.</p></div>';
782
+ }
783
+ }
784
+
785
+ function updateGalleryActionBar() {
786
+ const count = selectedGalleryPictures.size;
787
+ selectedCountText.textContent = count;
788
+
789
+ if (count > 0) {
790
+ galleryActionBar.classList.remove('hidden');
791
+ btnAnalyzeSelected.disabled = false;
792
+
793
+ if (isHeatmapMode) {
794
+ analyzeActionText.innerHTML = '<i class="ph-bold ph-map-trifold"></i> Gerar Mapa de Calor';
795
+ analyzeActionHint.textContent = `As ${count} imagens serão usadas no mapa.`;
796
+ if (btnDeleteSelected) btnDeleteSelected.style.display = 'none';
797
+ } else {
798
+ if (btnDeleteSelected) btnDeleteSelected.style.display = 'flex';
799
+ if (count === 1) {
800
+ analyzeActionText.innerHTML = '<i class="ph-bold ph-image"></i> Análise Individual';
801
+ analyzeActionHint.textContent = 'A imagem será enviada para a Análise Individual.';
802
+ } else {
803
+ analyzeActionText.innerHTML = '<i class="ph-bold ph-images"></i> Análise em Lote';
804
+ analyzeActionHint.textContent = `As ${count} imagens serão enviadas para Análise em Lote.`;
805
+ }
806
+ }
807
+ } else {
808
+ galleryActionBar.classList.add('hidden');
809
+ btnAnalyzeSelected.disabled = true;
810
+ }
811
+ }
812
+
813
+ // --- HEATMAP LOGIC ---
814
+ const baseMaps = {
815
+ dark: L.tileLayer('https://{s}.basemaps.cartocdn.com/dark_all/{z}/{x}/{y}{r}.png', {
816
+ attribution: '&copy; OpenStreetMap contributors &copy; CARTO',
817
+ subdomains: 'abcd',
818
+ maxZoom: 20
819
+ }),
820
+ osm: L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
821
+ maxZoom: 19,
822
+ attribution: '&copy; OpenStreetMap'
823
+ })
824
+ };
825
+
826
+ function openHeatmapModal() {
827
+ galleryModal.classList.add('hidden');
828
+ galleryModal.style.display = 'none';
829
+
830
+ heatmapModal.classList.remove('hidden');
831
+ heatmapModal.style.display = 'flex';
832
+
833
+ // Obter as imagens selecionadas
834
+ const selectedIds = Array.from(selectedGalleryPictures.values());
835
+ const selectedPics = currentGalleryPictures.filter(p => selectedIds.includes(p.id));
836
+
837
+ if (!leafletMapInstance) {
838
+ leafletMapInstance = L.map('leaflet-map').setView([-14.235, -51.925], 4);
839
+ baseMaps.dark.addTo(leafletMapInstance);
840
+
841
+ heatmapStyleFilter.addEventListener('change', (e) => {
842
+ if (e.target.value === 'dark') {
843
+ leafletMapInstance.removeLayer(baseMaps.osm);
844
+ baseMaps.dark.addTo(leafletMapInstance);
845
+ } else {
846
+ leafletMapInstance.removeLayer(baseMaps.dark);
847
+ baseMaps.osm.addTo(leafletMapInstance);
848
+ }
849
+ });
850
+
851
+ heatmapDiseaseFilter.addEventListener('change', () => {
852
+ updateHeatmapLayer(selectedPics);
853
+ });
854
+
855
+ // Adicionar Legenda
856
+ const legend = L.control({position: 'bottomright'});
857
+ legend.onAdd = function (map) {
858
+ const div = L.DomUtil.create('div', 'info legend');
859
+ div.style.backgroundColor = 'rgba(255, 255, 255, 0.9)';
860
+ div.style.padding = '10px';
861
+ div.style.borderRadius = '8px';
862
+ div.style.boxShadow = '0 0 15px rgba(0,0,0,0.2)';
863
+ div.style.color = '#333';
864
+ div.style.fontFamily = 'inherit';
865
+ div.innerHTML += '<strong style="display:block; margin-bottom: 5px;">Densidade de Focos</strong>';
866
+ div.innerHTML += '<i style="background: blue; width: 12px; height: 12px; display: inline-block; margin-right: 5px;"></i> Baixa<br>';
867
+ div.innerHTML += '<i style="background: lime; width: 12px; height: 12px; display: inline-block; margin-right: 5px;"></i> Média<br>';
868
+ div.innerHTML += '<i style="background: red; width: 12px; height: 12px; display: inline-block; margin-right: 5px;"></i> Alta<br>';
869
+ return div;
870
+ };
871
+ legend.addTo(leafletMapInstance);
872
+ }
873
+
874
+ setTimeout(() => {
875
+ leafletMapInstance.invalidateSize();
876
+ updateHeatmapLayer(selectedPics);
877
+
878
+ if (selectedPics.length > 0) {
879
+ const lats = selectedPics.map(p => p.lat).filter(Boolean);
880
+ const lngs = selectedPics.map(p => p.long).filter(Boolean);
881
+ if (lats.length > 0 && lngs.length > 0) {
882
+ const bounds = [
883
+ [Math.min(...lats), Math.min(...lngs)],
884
+ [Math.max(...lats), Math.max(...lngs)]
885
+ ];
886
+ leafletMapInstance.fitBounds(bounds, { padding: [50, 50] });
887
+ }
888
+ }
889
+ }, 300);
890
+ }
891
+
892
+ function updateHeatmapLayer(pics) {
893
+ if (currentHeatLayer) {
894
+ leafletMapInstance.removeLayer(currentHeatLayer);
895
+ }
896
+
897
+ const filterVal = heatmapDiseaseFilter.value;
898
+ const heatData = [];
899
+
900
+ pics.forEach(pic => {
901
+ if (pic.lat && pic.long) {
902
+ if (filterVal === 'all' || String(pic.id_disease) === filterVal) {
903
+ heatData.push([pic.lat, pic.long, 1]);
904
+ }
905
+ }
906
+ });
907
+
908
+ currentHeatLayer = L.heatLayer(heatData, {
909
+ radius: 25,
910
+ blur: 15,
911
+ maxZoom: 10,
912
+ gradient: {0.4: 'blue', 0.6: 'cyan', 0.7: 'lime', 0.8: 'yellow', 1.0: 'red'}
913
+ }).addTo(leafletMapInstance);
914
+ }
915
+
916
+ if (closeHeatmapBtn) {
917
+ closeHeatmapBtn.addEventListener('click', () => {
918
+ heatmapModal.classList.add('hidden');
919
+ heatmapModal.style.display = 'none';
920
+ });
921
+ }
922
+
923
+ async function urlToFile(url, filename, mimeType) {
924
+ const res = await fetch(url);
925
+ const buf = await res.arrayBuffer();
926
+ return new File([buf], filename, {type: mimeType});
927
+ }
928
+
929
+ if (btnSelectAll) {
930
+ btnSelectAll.addEventListener('click', () => {
931
+ // Select only what is currently rendered/filtered
932
+ const cards = galleryContainer.querySelectorAll('div > img'); // Hack to know there are cards
933
+ if (cards.length > 0) {
934
+ // Actually we just use currentGalleryPictures, but let's respect filters if we want.
935
+ // Re-run filter logic to get currently visible:
936
+ const descVal = filterDesc ? filterDesc.value.toLowerCase() : '';
937
+ const startVal = filterDateStart ? filterDateStart.value : '';
938
+ const endVal = filterDateEnd ? filterDateEnd.value : '';
939
+
940
+ const filtered = currentGalleryPictures.filter(pic => {
941
+ const picDesc = (pic.description || '').toLowerCase();
942
+ if (descVal && !picDesc.includes(descVal)) return false;
943
+
944
+ if (startVal || endVal) {
945
+ const picDateObj = new Date(pic.data);
946
+ const year = picDateObj.getFullYear();
947
+ const month = String(picDateObj.getMonth() + 1).padStart(2, '0');
948
+ const day = String(picDateObj.getDate()).padStart(2, '0');
949
+ const picDateStr = `${year}-${month}-${day}`;
950
+ if (startVal && picDateStr < startVal) return false;
951
+ if (endVal && picDateStr > endVal) return false;
952
+ }
953
+ return true;
954
+ });
955
+
956
+ filtered.forEach(pic => selectedGalleryPictures.set(pic.picture, pic.id));
957
+ applyGalleryFilters(); // Re-render to show selection styling
958
+ updateGalleryActionBar();
959
+ }
960
+ });
961
+ }
962
+
963
+ if (btnDeleteSelected) {
964
+ btnDeleteSelected.addEventListener('click', async () => {
965
+ if (selectedGalleryPictures.size === 0) return;
966
+ const confirmDelete = confirm(`Tem certeza que deseja apagar ${selectedGalleryPictures.size} foto(s)? Isso não pode ser desfeito.`);
967
+ if (!confirmDelete) return;
968
+
969
+ btnDeleteSelected.disabled = true;
970
+ btnDeleteSelected.innerHTML = '<i class="ph-duotone ph-spinner spin"></i> Apagando...';
971
+
972
+ try {
973
+ const idsToDelete = Array.from(selectedGalleryPictures.values());
974
+ const urlsToDelete = Array.from(selectedGalleryPictures.keys());
975
+
976
+ // 1. Delete from DB
977
+ const { error: dbError } = await supabaseClient.from('pictures').delete().in('id', idsToDelete);
978
+ if (dbError) throw new Error("Erro ao apagar do banco de dados: " + dbError.message);
979
+
980
+ // 2. Delete from Storage
981
+ const storagePaths = urlsToDelete.map(url => {
982
+ // Extract filename from the end of publicUrl
983
+ return url.split('/').pop();
984
+ });
985
+ const { error: stError } = await supabaseClient.storage.from('FotosCafe').remove(storagePaths);
986
+ if (stError) console.warn("Aviso: Falha ao apagar arquivos do storage", stError);
987
+
988
+ // Update UI state
989
+ currentGalleryPictures = currentGalleryPictures.filter(p => !idsToDelete.includes(p.id));
990
+ selectedGalleryPictures.clear();
991
+
992
+ alert('Fotos apagadas com sucesso!');
993
+ applyGalleryFilters();
994
+ updateGalleryActionBar();
995
+
996
+ } catch (err) {
997
+ alert(err.message);
998
+ } finally {
999
+ btnDeleteSelected.disabled = false;
1000
+ btnDeleteSelected.innerHTML = '<i class="ph-bold ph-trash"></i> Excluir';
1001
+ }
1002
+ });
1003
+ }
1004
+
1005
+ if (btnAnalyzeSelected) {
1006
+ btnAnalyzeSelected.addEventListener('click', async () => {
1007
+ if (isHeatmapMode) {
1008
+ openHeatmapModal();
1009
+ return;
1010
+ }
1011
+
1012
+ const items = Array.from(selectedGalleryPictures.entries()); // [url, id]
1013
+ if (items.length === 0) return;
1014
+
1015
+ btnAnalyzeSelected.disabled = true;
1016
+ analyzeActionText.textContent = 'Baixando imagens...';
1017
+ analyzeActionLoader.classList.remove('hidden');
1018
+
1019
+ try {
1020
+ // Fechar modal
1021
+ galleryModal.classList.add('hidden');
1022
+ galleryModal.style.display = 'none';
1023
+
1024
+ // Baixar arquivos
1025
+ const files = [];
1026
+ for (let i = 0; i < items.length; i++) {
1027
+ const [url, picId] = items[i];
1028
+ const filename = `foto_appcafe_${picId}.jpg`;
1029
+ const file = await urlToFile(url, filename, 'image/jpeg');
1030
+ file.supabasePictureId = picId;
1031
+ files.push(file);
1032
+ }
1033
+
1034
+ if (files.length === 1) {
1035
+ // Send to Individual Analysis
1036
+ tabIndividual.click();
1037
+ handleFile(files[0]);
1038
+ // Auto click analyze
1039
+ setTimeout(() => analyzeBtn.click(), 500);
1040
+ } else {
1041
+ // Send to Batch Analysis
1042
+ tabBatch.click();
1043
+ handleBatchFiles(files);
1044
+ // Auto click analyze
1045
+ setTimeout(() => batchAnalyzeBtn.click(), 500);
1046
+ }
1047
+
1048
+ // Limpar seleção apos enviar
1049
+ selectedGalleryPictures.clear();
1050
+ updateGalleryActionBar();
1051
+
1052
+ } catch (error) {
1053
+ console.error("Erro ao baixar imagens:", error);
1054
+ alert("Ocorreu um erro ao preparar as imagens para análise. Tente novamente.");
1055
+ galleryModal.classList.remove('hidden');
1056
+ galleryModal.style.display = 'flex';
1057
+ } finally {
1058
+ btnAnalyzeSelected.disabled = false;
1059
+ analyzeActionLoader.classList.add('hidden');
1060
+ }
1061
+ });
1062
+ }
1063
+
1064
+ function applyGalleryFilters() {
1065
+ if (!currentGalleryPictures) return;
1066
+
1067
+ const descVal = filterDesc ? filterDesc.value.toLowerCase() : '';
1068
+ const startVal = filterDateStart ? filterDateStart.value : '';
1069
+ const endVal = filterDateEnd ? filterDateEnd.value : '';
1070
+ const filterDiseaseSelect = document.getElementById('filter-disease-select');
1071
+ const diseaseVal = filterDiseaseSelect && !filterDiseaseSelect.parentElement.classList.contains('hidden') ? filterDiseaseSelect.value : 'all';
1072
+
1073
+ const filtered = currentGalleryPictures.filter(pic => {
1074
+ const picDesc = (pic.description || '').toLowerCase();
1075
+ if (descVal && !picDesc.includes(descVal)) return false;
1076
+
1077
+ if (startVal || endVal) {
1078
+ const picDateObj = new Date(pic.data);
1079
+ const year = picDateObj.getFullYear();
1080
+ const month = String(picDateObj.getMonth() + 1).padStart(2, '0');
1081
+ const day = String(picDateObj.getDate()).padStart(2, '0');
1082
+ const picDateStr = `${year}-${month}-${day}`;
1083
+
1084
+ if (startVal && picDateStr < startVal) return false;
1085
+ if (endVal && picDateStr > endVal) return false;
1086
+ }
1087
+
1088
+ if (diseaseVal !== 'all' && String(pic.id_disease) !== diseaseVal) {
1089
+ return false;
1090
+ }
1091
+
1092
+ return true;
1093
+ });
1094
+
1095
+ renderGalleryCards(filtered);
1096
+ }
1097
+
1098
+ if (filterDesc) filterDesc.addEventListener('input', applyGalleryFilters);
1099
+ if (filterDateStart) filterDateStart.addEventListener('change', applyGalleryFilters);
1100
+ if (filterDateEnd) filterDateEnd.addEventListener('change', applyGalleryFilters);
1101
+ const filterDiseaseSelect = document.getElementById('filter-disease-select');
1102
+ if (filterDiseaseSelect) filterDiseaseSelect.addEventListener('change', applyGalleryFilters);
1103
+
1104
+ if (clearFiltersBtn) {
1105
+ clearFiltersBtn.addEventListener('click', () => {
1106
+ if (filterDesc) filterDesc.value = '';
1107
+ if (filterDateStart) filterDateStart.value = '';
1108
+ if (filterDateEnd) filterDateEnd.value = '';
1109
+ if (filterDiseaseSelect) filterDiseaseSelect.value = 'all';
1110
+ applyGalleryFilters();
1111
+ });
1112
+ }
1113
+
1114
+ // --- DASHBOARD ACTIONS ---
1115
+ if (btnDashboardIndividual) {
1116
+ btnDashboardIndividual.addEventListener('click', () => {
1117
+ tabIndividual.click();
1118
+ });
1119
+ }
1120
+
1121
+ if (btnDashboardLote) {
1122
+ btnDashboardLote.addEventListener('click', () => {
1123
+ tabBatch.click();
1124
+ });
1125
+ }
1126
+
1127
+ if (btnMapaCalor) {
1128
+ btnMapaCalor.addEventListener('click', async () => {
1129
+ isHeatmapMode = true;
1130
+ btnOlharFotos.click(); // Reuse gallery open logic but in heatmap mode
1131
+ });
1132
+ }
1133
+
1134
+ if (btnOlharFotos) {
1135
+ btnOlharFotos.addEventListener('click', async (e) => {
1136
+ if (!supabaseClient) return;
1137
+
1138
+ // If click was explicitly from "Olhar Fotos", disable heatmap mode
1139
+ if (e && e.isTrusted) {
1140
+ isHeatmapMode = false;
1141
+ }
1142
+
1143
+ // Abre o modal
1144
+ galleryModal.classList.remove('hidden');
1145
+ galleryModal.style.display = 'flex';
1146
+ galleryContainer.innerHTML = '';
1147
+ galleryLoader.classList.remove('hidden');
1148
+
1149
+ // Set gallery title
1150
+ const galleryTitle = galleryModal.querySelector('h2');
1151
+ if (isHeatmapMode) {
1152
+ galleryTitle.innerHTML = '<i class="ph-bold ph-map-trifold"></i> Selecionar Fotos para Mapa de Calor';
1153
+ } else {
1154
+ galleryTitle.innerHTML = '<i class="ph-bold ph-images"></i> Suas Fotos';
1155
+ }
1156
+
1157
+ try {
1158
+ // Preload diseases if not loaded
1159
+ if (Object.keys(diseaseDictionary).length === 0) {
1160
+ const { data: dData, error: dErr } = await supabaseClient.from('diseases').select('*');
1161
+ if (!dErr && dData) {
1162
+ dData.forEach(d => { diseaseDictionary[d.id] = d.name; });
1163
+
1164
+ // Populate heatmap dropdown
1165
+ heatmapDiseaseFilter.innerHTML = '<option value="all" style="color: black;">Todas as Doenças</option>';
1166
+ dData.forEach(d => {
1167
+ heatmapDiseaseFilter.innerHTML += `<option value="${d.id}" style="color: black;">Apenas ${d.name}</option>`;
1168
+ });
1169
+ }
1170
+ }
1171
+
1172
+ // Pega o ID do usuario logado
1173
+ const { data: userData, error: userError } = await supabaseClient.auth.getUser();
1174
+ if (userError || !userData?.user) throw new Error('Erro ao identificar usuário logado.');
1175
+
1176
+ const userId = userData.user.id;
1177
+
1178
+ // Busca as fotos na tabela pictures
1179
+ let query = supabaseClient.from('pictures').select('*').eq('id_user', userId);
1180
+ if (isHeatmapMode) {
1181
+ query = query.not('id_disease', 'is', null);
1182
+ }
1183
+
1184
+ const { data: pictures, error: picError } = await query.order('data', { ascending: false });
1185
+
1186
+ if (picError) throw new Error(picError.message);
1187
+
1188
+ currentGalleryPictures = pictures || [];
1189
+ galleryLoader.classList.add('hidden');
1190
+
1191
+ if (currentGalleryPictures.length > 0) {
1192
+ if (galleryFilters) galleryFilters.classList.remove('hidden');
1193
+
1194
+ const filterDiseaseContainer = document.getElementById('filter-disease-container');
1195
+ const filterDiseaseSelectEl = document.getElementById('filter-disease-select');
1196
+
1197
+ if (isHeatmapMode && filterDiseaseContainer && filterDiseaseSelectEl) {
1198
+ filterDiseaseContainer.classList.remove('hidden');
1199
+ filterDiseaseSelectEl.innerHTML = '<option value="all" style="color: black;">Todas as Doenças</option>';
1200
+ Object.entries(diseaseDictionary).forEach(([id, name]) => {
1201
+ filterDiseaseSelectEl.innerHTML += `<option value="${id}" style="color: black;">${name}</option>`;
1202
+ });
1203
+ } else if (filterDiseaseContainer) {
1204
+ filterDiseaseContainer.classList.add('hidden');
1205
+ }
1206
+
1207
+ // Reset filters on fresh open
1208
+ if (filterDesc) filterDesc.value = '';
1209
+ if (filterDateStart) filterDateStart.value = '';
1210
+ if (filterDateEnd) filterDateEnd.value = '';
1211
+ if (filterDiseaseSelectEl) filterDiseaseSelectEl.value = 'all';
1212
+
1213
+ // Reset selection
1214
+ selectedGalleryPictures.clear();
1215
+ updateGalleryActionBar();
1216
+
1217
+ applyGalleryFilters();
1218
+ } else {
1219
+ if (galleryFilters) galleryFilters.classList.add('hidden');
1220
+ selectedGalleryPictures.clear();
1221
+ updateGalleryActionBar();
1222
+ renderGalleryCards([]);
1223
+ }
1224
+
1225
+ } catch (error) {
1226
+ console.error("Erro ao buscar fotos:", error);
1227
+ galleryLoader.classList.add('hidden');
1228
+ galleryContainer.innerHTML = `<p style="color: #fc8181; grid-column: 1 / -1; text-align: center;">Erro ao carregar fotos: ${error.message}</p>`;
1229
+ }
1230
+ });
1231
+ }
1232
+
1233
+ if (closeGalleryBtn) {
1234
+ closeGalleryBtn.addEventListener('click', () => {
1235
+ galleryModal.classList.add('hidden');
1236
+ galleryModal.style.display = 'none';
1237
+ });
1238
+ }
1239
+
1240
  });
static/style.css CHANGED
@@ -428,3 +428,78 @@ header {
428
  .crops-gallery::-webkit-scrollbar-thumb:hover {
429
  background: rgba(47, 133, 90, 0.7);
430
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  .crops-gallery::-webkit-scrollbar-thumb:hover {
429
  background: rgba(47, 133, 90, 0.7);
430
  }
431
+
432
+ /* Tabs Navigation */
433
+ .tabs-container {
434
+ display: flex;
435
+ justify-content: center;
436
+ gap: 1rem;
437
+ margin-bottom: 2rem;
438
+ }
439
+
440
+ .tab-btn {
441
+ background: var(--glass-bg);
442
+ border: 1px solid var(--glass-border);
443
+ padding: 0.75rem 1.5rem;
444
+ border-radius: 12px;
445
+ font-size: 1rem;
446
+ font-weight: 600;
447
+ color: var(--text-secondary);
448
+ cursor: pointer;
449
+ transition: all 0.3s ease;
450
+ display: flex;
451
+ align-items: center;
452
+ gap: 8px;
453
+ backdrop-filter: blur(12px);
454
+ -webkit-backdrop-filter: blur(12px);
455
+ }
456
+
457
+ .tab-btn:hover {
458
+ background: rgba(255, 255, 255, 0.95);
459
+ transform: translateY(-2px);
460
+ }
461
+
462
+ .tab-btn.active {
463
+ background: var(--accent-primary);
464
+ color: white;
465
+ border-color: var(--accent-primary);
466
+ box-shadow: 0 4px 15px rgba(47, 133, 90, 0.2);
467
+ }
468
+
469
+ /* Batch Preview Grid */
470
+ .batch-preview-grid {
471
+ position: absolute;
472
+ top: 0;
473
+ left: 0;
474
+ width: 100%;
475
+ height: 100%;
476
+ padding: 1rem;
477
+ display: grid;
478
+ grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
479
+ gap: 8px;
480
+ overflow-y: auto;
481
+ background: rgba(255, 255, 255, 0.95);
482
+ border-radius: 16px;
483
+ align-items: flex-start;
484
+ align-content: flex-start;
485
+ }
486
+
487
+ .batch-preview-grid img {
488
+ width: 100%;
489
+ aspect-ratio: 1;
490
+ object-fit: cover;
491
+ border-radius: 8px;
492
+ border: 1px solid var(--glass-border);
493
+ box-shadow: 0 2px 5px rgba(0,0,0,0.1);
494
+ }
495
+
496
+ .batch-preview-grid::-webkit-scrollbar {
497
+ width: 6px;
498
+ }
499
+ .batch-preview-grid::-webkit-scrollbar-track {
500
+ background: transparent;
501
+ }
502
+ .batch-preview-grid::-webkit-scrollbar-thumb {
503
+ background: rgba(47, 133, 90, 0.3);
504
+ border-radius: 4px;
505
+ }
templates/index.html CHANGED
@@ -12,7 +12,17 @@
12
  <script src="https://unpkg.com/@phosphor-icons/web"></script>
13
  <!-- Chart.js -->
14
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
 
 
 
15
  <link rel="stylesheet" href="/static/style.css">
 
 
 
 
 
 
 
16
  </head>
17
  <body>
18
  <main class="container">
@@ -21,7 +31,13 @@
21
  <p class="subtitle"><i class="ph ph-brain"></i> CADIS: Coffee AI Disease System - Diagnóstico avançado de doenças em folhas de café</p>
22
  </header>
23
 
24
- <div class="content-wrapper">
 
 
 
 
 
 
25
  <!-- Left Side: Upload and Info -->
26
  <section class="upload-section glass-panel">
27
  <div class="upload-area" id="upload-area">
@@ -59,6 +75,14 @@
59
  <div class="stat-card highlight">
60
  <span class="stat-label"><i class="ph-bold ph-virus"></i> Doença Principal</span>
61
  <span class="stat-value" id="main-disease">-</span>
 
 
 
 
 
 
 
 
62
  </div>
63
  <div class="stat-card highlight" style="display: flex; align-items: center; justify-content: center; padding: 10px;">
64
  <p style="font-size: 0.85rem; color: #718096; margin: 0; text-align: center; line-height: 1.4;">
@@ -81,6 +105,194 @@
81
  </div>
82
  </section>
83
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
  </main>
85
 
86
  <script src="/static/script.js"></script>
 
12
  <script src="https://unpkg.com/@phosphor-icons/web"></script>
13
  <!-- Chart.js -->
14
  <script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
15
+ <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin=""/>
16
+ <script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
17
+ <script src="https://unpkg.com/leaflet.heat@0.2.0/dist/leaflet-heat.js"></script>
18
  <link rel="stylesheet" href="/static/style.css">
19
+
20
+ <!-- Supabase JS -->
21
+ <script src="https://cdn.jsdelivr.net/npm/@supabase/supabase-js@2"></script>
22
+ <script>
23
+ window.SUPABASE_URL = "{{ supabase_url }}";
24
+ window.SUPABASE_ANON_KEY = "{{ supabase_key }}";
25
+ </script>
26
  </head>
27
  <body>
28
  <main class="container">
 
31
  <p class="subtitle"><i class="ph ph-brain"></i> CADIS: Coffee AI Disease System - Diagnóstico avançado de doenças em folhas de café</p>
32
  </header>
33
 
34
+ <div class="tabs-container">
35
+ <button class="tab-btn active" id="tab-individual"><i class="ph-bold ph-image"></i> Análise Individual</button>
36
+ <button class="tab-btn" id="tab-batch"><i class="ph-bold ph-images"></i> Análise em Lote</button>
37
+ <button class="tab-btn" id="tab-appcafe"><i class="ph-bold ph-device-mobile"></i> AppCafe</button>
38
+ </div>
39
+
40
+ <div class="content-wrapper" id="wrapper-individual">
41
  <!-- Left Side: Upload and Info -->
42
  <section class="upload-section glass-panel">
43
  <div class="upload-area" id="upload-area">
 
75
  <div class="stat-card highlight">
76
  <span class="stat-label"><i class="ph-bold ph-virus"></i> Doença Principal</span>
77
  <span class="stat-value" id="main-disease">-</span>
78
+
79
+ <!-- Botão Salvar Resultado -->
80
+ <div id="save-result-container-individual" class="hidden" style="margin-top: 15px;">
81
+ <button id="btn-save-result-individual" class="primary-btn" style="background-color: var(--accent-secondary); border-color: var(--accent-secondary); padding: 8px 16px; font-size: 0.9rem;">
82
+ <i class="ph-bold ph-floppy-disk"></i> Salvar Resultado
83
+ </button>
84
+ <p id="save-result-msg-individual" style="font-size: 0.85rem; color: var(--text-secondary); margin-top: 8px; display: none;"></p>
85
+ </div>
86
  </div>
87
  <div class="stat-card highlight" style="display: flex; align-items: center; justify-content: center; padding: 10px;">
88
  <p style="font-size: 0.85rem; color: #718096; margin: 0; text-align: center; line-height: 1.4;">
 
105
  </div>
106
  </section>
107
  </div>
108
+
109
+ <div class="content-wrapper hidden" id="wrapper-batch">
110
+ <!-- Left Side: Batch Upload -->
111
+ <section class="upload-section glass-panel">
112
+ <div class="upload-area" id="batch-upload-area">
113
+ <input type="file" id="batch-file-input" accept="image/*" multiple hidden>
114
+ <div class="upload-content" id="batch-upload-content">
115
+ <i class="ph-duotone ph-files upload-icon"></i>
116
+ <h3>Selecione várias imagens</h3>
117
+ <p>Arraste e solte ou clique aqui</p>
118
+ </div>
119
+ <div id="batch-image-preview" class="hidden batch-preview-grid"></div>
120
+ </div>
121
+
122
+ <div class="upload-notice">
123
+ <i class="ph-fill ph-info"></i>
124
+ <p><strong>Lote:</strong> Selecione múltiplas folhas. O resultado será um resumo geral de todas as imagens processadas.</p>
125
+ </div>
126
+
127
+ <button id="batch-analyze-btn" class="primary-btn" disabled>
128
+ <span class="btn-text" id="batch-btn-text"><i class="ph-bold ph-scan"></i> Analisar Lote</span>
129
+ <span class="loader hidden" id="batch-loader"></span>
130
+ </button>
131
+ </section>
132
+
133
+ <!-- Right Side: Batch Results -->
134
+ <section class="results-section glass-panel" id="batch-results-panel">
135
+ <div class="results-placeholder" id="batch-results-placeholder">
136
+ <div class="pulse-ring"><i class="ph-duotone ph-chart-pie" style="font-size: 2.5rem; color: var(--accent-primary);"></i></div>
137
+ <p>Aguardando imagens para análise em lote...</p>
138
+ </div>
139
+
140
+ <div class="results-content hidden" id="batch-results-content">
141
+ <h2><i class="ph-bold ph-chart-bar"></i> Resumo do Lote</h2>
142
+
143
+ <div class="stats-grid">
144
+ <div class="stat-card highlight">
145
+ <span class="stat-label"><i class="ph-bold ph-leaf"></i> Folhas Analisadas</span>
146
+ <span class="stat-value" id="batch-total-leaves">-</span>
147
+ </div>
148
+ <div class="stat-card highlight">
149
+ <span class="stat-label"><i class="ph-bold ph-virus"></i> Principal Incidência</span>
150
+ <span class="stat-value" id="batch-main-disease" style="font-size: 1.2rem;">-</span>
151
+ </div>
152
+ </div>
153
+
154
+ <div style="background-color: white; border: 1px solid #e2e8f0; border-radius: 12px; padding: 25px; margin-bottom: 25px;">
155
+ <h3 style="color: var(--text-secondary); font-size: 0.9rem; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 15px; text-align: center;">Estatísticas Gerais</h3>
156
+ <div style="height: 250px; position: relative;">
157
+ <canvas id="batchResultsChart"></canvas>
158
+ </div>
159
+
160
+ <!-- Botão Salvar Resultados Lote -->
161
+ <div id="save-result-container-batch" class="hidden" style="margin-top: 20px; text-align: center; border-top: 1px solid #e2e8f0; padding-top: 20px;">
162
+ <button id="btn-save-result-batch" class="primary-btn" style="background-color: var(--accent-secondary); border-color: var(--accent-secondary); padding: 10px 20px;">
163
+ <i class="ph-bold ph-floppy-disk-back"></i> Salvar Todos os Resultados
164
+ </button>
165
+ <p id="save-result-msg-batch" style="font-size: 0.85rem; color: var(--text-secondary); margin-top: 8px; display: none;"></p>
166
+ </div>
167
+ </div>
168
+ </div>
169
+ </section>
170
+ </div>
171
+
172
+ <div class="content-wrapper hidden" id="wrapper-appcafe" style="display: flex; justify-content: center; width: 100%;">
173
+ <section class="upload-section glass-panel" style="margin: 0 auto; max-width: 500px; width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; min-height: 400px;">
174
+ <div id="appcafe-login-container" style="width: 100%; text-align: center;">
175
+ <h2 style="margin-bottom: 10px; display: flex; align-items: center; justify-content: center; gap: 8px;"><i class="ph-bold ph-user-circle"></i> Login AppCafe</h2>
176
+ <p style="color: var(--text-secondary); margin-bottom: 20px; font-size: 0.95rem;">Faça login com sua conta do aplicativo para gerenciar suas análises.</p>
177
+
178
+ <form id="appcafe-login-form" style="display: flex; flex-direction: column; gap: 15px;">
179
+ <input type="email" id="appcafe-email" placeholder="Seu e-mail" required style="padding: 12px; border-radius: 8px; border: 1px solid #e2e8f0; font-family: inherit; font-size: 1rem; width: 100%;">
180
+ <input type="password" id="appcafe-password" placeholder="Sua senha" required style="padding: 12px; border-radius: 8px; border: 1px solid #e2e8f0; font-family: inherit; font-size: 1rem; width: 100%;">
181
+
182
+ <div style="display: flex; flex-direction: column; gap: 10px;">
183
+ <button type="submit" class="primary-btn" id="appcafe-login-btn">
184
+ <span class="btn-text">Entrar</span>
185
+ <span class="loader hidden" id="appcafe-loader"></span>
186
+ </button>
187
+ <a href="#" id="appcafe-forgot-password" style="color: var(--text-secondary); font-size: 0.9rem; text-decoration: underline;">Esqueci minha senha</a>
188
+ </div>
189
+ <p id="appcafe-error" style="color: #e53e3e; font-size: 0.9rem; margin-top: 5px; display: none; text-align: center;"></p>
190
+ </form>
191
+ </div>
192
+
193
+ <div id="appcafe-logged-in" class="hidden" style="width: 100%; text-align: center;">
194
+ <i class="ph-fill ph-check-circle" style="color: var(--accent-primary); font-size: 3rem; margin-bottom: 10px;"></i>
195
+ <h2 id="welcome-user-text" style="margin-bottom: 10px;">Bem-vindo(a)!</h2>
196
+ <p style="color: var(--text-secondary); margin-bottom: 25px;">Escolha o que você deseja fazer abaixo:</p>
197
+
198
+ <div style="display: grid; grid-template-columns: 1fr 1fr; gap: 15px; margin-bottom: 25px;">
199
+ <button id="btn-olhar-fotos" class="tab-btn" style="display: flex; flex-direction: column; align-items: center; gap: 5px; padding: 20px 10px; height: auto;"><i class="ph-bold ph-images" style="font-size: 1.5rem;"></i> Olhar Fotos</button>
200
+ <button id="btn-mapa-calor" class="tab-btn" style="display: flex; flex-direction: column; align-items: center; gap: 5px; padding: 20px 10px; height: auto;"><i class="ph-bold ph-map-pin" style="font-size: 1.5rem;"></i> Mapa de Calor</button>
201
+ <button id="btn-dashboard-individual" class="tab-btn" style="display: flex; flex-direction: column; align-items: center; gap: 5px; padding: 20px 10px; height: auto;"><i class="ph-bold ph-image" style="font-size: 1.5rem;"></i> Análise Individual</button>
202
+ <button id="btn-dashboard-lote" class="tab-btn" style="display: flex; flex-direction: column; align-items: center; gap: 5px; padding: 20px 10px; height: auto;"><i class="ph-bold ph-files" style="font-size: 1.5rem;"></i> Análise em Lote</button>
203
+ </div>
204
+
205
+ <button id="appcafe-logout-btn" class="secondary-btn" style="width: 100%; border: 1px solid #e2e8f0; background: transparent;">Sair da Conta</button>
206
+ </div>
207
+
208
+ <div class="appcafe-promo" style="margin-top: 30px; padding-top: 25px; border-top: 1px solid #e2e8f0; text-align: center; width: 100%;">
209
+ <h3 style="margin-bottom: 12px;">Ainda não tem o AppCafe?</h3>
210
+ <p style="color: var(--text-secondary); margin-bottom: 15px; font-size: 0.9rem; line-height: 1.5;">Utilize o nosso app para tirar fotos no campo, receber diagnósticos rápidos e manter um histórico seguro das suas análises.</p>
211
+ <a href="#" style="display: inline-flex; align-items: center; gap: 8px; padding: 12px 24px; background-color: var(--accent-primary); color: white; text-decoration: none; border-radius: 8px; font-weight: 600; transition: opacity 0.2s;"><i class="ph-bold ph-download-simple"></i> Baixar AppCafe</a>
212
+ </div>
213
+ </section>
214
+ </div>
215
+
216
+ <!-- Fullscreen Gallery Modal for "Olhar Fotos" -->
217
+ <div id="gallery-modal" class="hidden" style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: rgba(15, 23, 42, 0.98); z-index: 1000; display: flex; flex-direction: column; overflow-y: auto; backdrop-filter: blur(10px);">
218
+ <div style="padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; background: rgba(0,0,0,0.2); border-bottom: 1px solid rgba(255,255,255,0.1);">
219
+ <h2 style="color: white; margin: 0; display: flex; align-items: center; gap: 10px;"><i class="ph-bold ph-images"></i> Suas Fotos</h2>
220
+ <button id="close-gallery-btn" style="background: #e53e3e; border: none; color: white; font-size: 1.5rem; cursor: pointer; border-radius: 50%; width: 45px; height: 45px; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 6px rgba(0,0,0,0.3); transition: transform 0.2s;" onmouseover="this.style.transform='scale(1.1)'" onmouseout="this.style.transform='scale(1)'"><i class="ph-bold ph-x"></i></button>
221
+ </div>
222
+
223
+ <!-- Filters -->
224
+ <div id="gallery-filters" class="hidden" style="padding: 20px 40px; background: rgba(0,0,0,0.3); display: flex; gap: 15px; flex-wrap: wrap; align-items: flex-end; border-bottom: 1px solid rgba(255,255,255,0.05); justify-content: center;">
225
+ <div id="filter-disease-container" class="hidden" style="display: flex; flex-direction: column; gap: 5px;">
226
+ <label style="color: #cbd5e1; font-size: 0.85rem; font-weight: 600;"><i class="ph-bold ph-virus"></i> Doença:</label>
227
+ <select id="filter-disease-select" style="padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.1); color: white; min-width: 200px; font-family: inherit; cursor: pointer;">
228
+ <option value="all" style="color: black;">Todas</option>
229
+ </select>
230
+ </div>
231
+ <div style="display: flex; flex-direction: column; gap: 5px;">
232
+ <label style="color: #cbd5e1; font-size: 0.85rem; font-weight: 600;"><i class="ph-bold ph-magnifying-glass"></i> Descrição:</label>
233
+ <input type="text" id="filter-desc" placeholder="Buscar..." style="padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.1); color: white; min-width: 200px; font-family: inherit;">
234
+ </div>
235
+ <div style="display: flex; flex-direction: column; gap: 5px;">
236
+ <label style="color: #cbd5e1; font-size: 0.85rem; font-weight: 600;"><i class="ph-bold ph-calendar"></i> Data Inicial:</label>
237
+ <input type="date" id="filter-date-start" style="padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.1); color: white; font-family: inherit; color-scheme: dark;">
238
+ </div>
239
+ <div style="display: flex; flex-direction: column; gap: 5px;">
240
+ <label style="color: #cbd5e1; font-size: 0.85rem; font-weight: 600;"><i class="ph-bold ph-calendar"></i> Data Final:</label>
241
+ <input type="date" id="filter-date-end" style="padding: 10px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.1); color: white; font-family: inherit; color-scheme: dark;">
242
+ </div>
243
+ <div style="display: flex; gap: 10px;">
244
+ <button id="clear-filters-btn" style="padding: 10px 20px; background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.3); color: white; border-radius: 8px; cursor: pointer; transition: background 0.2s; font-weight: 600; display: flex; align-items: center; gap: 5px;" onmouseover="this.style.background='rgba(255,255,255,0.2)'" onmouseout="this.style.background='rgba(255,255,255,0.1)'"><i class="ph-bold ph-eraser"></i> Limpar</button>
245
+ <button id="select-all-btn" style="padding: 10px 20px; background: rgba(66, 153, 225, 0.2); border: 1px solid rgba(66, 153, 225, 0.5); color: #63b3ed; border-radius: 8px; cursor: pointer; transition: all 0.2s; font-weight: 600; display: flex; align-items: center; gap: 5px;" onmouseover="this.style.background='rgba(66, 153, 225, 0.3)'" onmouseout="this.style.background='rgba(66, 153, 225, 0.2)'"><i class="ph-bold ph-check-square"></i> Selecionar Tudo</button>
246
+ </div>
247
+ </div>
248
+ <div id="gallery-container" style="padding: 40px; display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); justify-content: center; gap: 30px; max-width: 1200px; margin: 0 auto; width: 100%; padding-bottom: 100px;">
249
+ <!-- Imagens serão injetadas aqui -->
250
+ </div>
251
+ <div id="gallery-loader" class="hidden" style="text-align: center; padding: 40px; color: white;">
252
+ <div class="pulse-ring" style="margin: 0 auto; display: block; border-color: rgba(255,255,255,0.5);"><i class="ph-duotone ph-spinner" style="font-size: 2.5rem; animation: spin 1s linear infinite; color: white;"></i></div>
253
+ <p style="margin-top: 15px; font-weight: 600; letter-spacing: 1px;">Carregando sua galeria...</p>
254
+ </div>
255
+
256
+ <!-- Selection Action Bar -->
257
+ <div id="gallery-action-bar" class="hidden" style="position: sticky; bottom: 0; left: 0; width: 100%; background: rgba(15, 23, 42, 0.95); border-top: 1px solid rgba(255,255,255,0.1); padding: 15px 40px; display: flex; justify-content: space-between; align-items: center; z-index: 1010; backdrop-filter: blur(5px); margin-top: auto;">
258
+ <p style="color: white; margin: 0; font-weight: 600; font-size: 1.1rem;"><span id="selected-count" style="color: var(--accent-primary); font-size: 1.3rem;">0</span> foto(s) selecionada(s)</p>
259
+ <div style="display: flex; gap: 15px; align-items: center;">
260
+ <span id="analyze-action-hint" style="color: #94a3b8; font-size: 0.9rem; margin-right: 10px;">Ações para seleção</span>
261
+ <button id="btn-delete-selected" style="padding: 10px 20px; background: rgba(229, 62, 62, 0.2); border: 1px solid rgba(229, 62, 62, 0.5); color: #fc8181; border-radius: 8px; cursor: pointer; transition: all 0.2s; font-weight: 600; display: flex; align-items: center; gap: 5px;" onmouseover="this.style.background='rgba(229, 62, 62, 0.4)'" onmouseout="this.style.background='rgba(229, 62, 62, 0.2)'"><i class="ph-bold ph-trash"></i> Excluir</button>
262
+ <button id="btn-cancel-selection" style="padding: 10px 20px; background: transparent; border: 1px solid rgba(255,255,255,0.3); color: white; border-radius: 8px; cursor: pointer; transition: background 0.2s; font-weight: 600;" onmouseover="this.style.background='rgba(255,255,255,0.1)'" onmouseout="this.style.background='transparent'">Cancelar</button>
263
+ <button id="btn-analyze-selected" class="primary-btn" style="padding: 10px 20px; min-width: 200px; display: flex; justify-content: center;" disabled>
264
+ <span class="btn-text" id="analyze-action-text"><i class="ph-bold ph-scan"></i> Analisar</span>
265
+ <span class="loader hidden" id="analyze-action-loader"></span>
266
+ </button>
267
+ </div>
268
+ </div>
269
+ </div>
270
+
271
+ <!-- Heatmap Modal -->
272
+ <div id="heatmap-modal" class="hidden" style="position: fixed; top: 0; left: 0; width: 100vw; height: 100vh; background: #0f172a; z-index: 2000; display: flex; flex-direction: column;">
273
+ <div style="padding: 20px 40px; display: flex; justify-content: space-between; align-items: center; background: rgba(0,0,0,0.3); border-bottom: 1px solid rgba(255,255,255,0.1);">
274
+ <h2 style="color: white; margin: 0; display: flex; align-items: center; gap: 10px;"><i class="ph-bold ph-map-trifold"></i> Mapa de Calor Analítico</h2>
275
+ <div style="display: flex; gap: 20px; align-items: center;">
276
+ <div style="display: flex; flex-direction: column; gap: 5px;">
277
+ <label style="color: #cbd5e1; font-size: 0.85rem; font-weight: 600;">Filtrar Doença:</label>
278
+ <select id="heatmap-disease-filter" style="padding: 8px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.1); color: white; font-family: inherit; cursor: pointer;">
279
+ <option value="all" style="color: black;">Todas as Doenças</option>
280
+ </select>
281
+ </div>
282
+ <div style="display: flex; flex-direction: column; gap: 5px;">
283
+ <label style="color: #cbd5e1; font-size: 0.85rem; font-weight: 600;">Estilo do Mapa:</label>
284
+ <select id="heatmap-style-filter" style="padding: 8px 12px; border-radius: 8px; border: 1px solid rgba(255,255,255,0.2); background: rgba(255,255,255,0.1); color: white; font-family: inherit; cursor: pointer;">
285
+ <option value="dark" style="color: black;">Simplificado (Escuro)</option>
286
+ <option value="osm" style="color: black;">Detalhado (Satélite/Mapas)</option>
287
+ </select>
288
+ </div>
289
+ <button id="close-heatmap-btn" style="background: #e53e3e; border: none; color: white; font-size: 1.5rem; cursor: pointer; border-radius: 50%; width: 45px; height: 45px; display: flex; align-items: center; justify-content: center; box-shadow: 0 4px 6px rgba(0,0,0,0.3); transition: transform 0.2s; margin-left: 10px;" onmouseover="this.style.transform='scale(1.1)'" onmouseout="this.style.transform='scale(1)'"><i class="ph-bold ph-x"></i></button>
290
+ </div>
291
+ </div>
292
+ <div id="heatmap-container" style="flex-grow: 1; width: 100%; position: relative;">
293
+ <div id="leaflet-map" style="width: 100%; height: 100%; z-index: 1;"></div>
294
+ </div>
295
+ </div>
296
  </main>
297
 
298
  <script src="/static/script.js"></script>