Spaces:
Sleeping
Sleeping
Atualizando app.py com chaves do Supabase e Roboflow
Browse files- app.py +137 -112
- requirements.txt +1 -0
- static/script.js +1034 -1
- static/style.css +75 -0
- templates/index.html +213 -1
app.py
CHANGED
|
@@ -113,7 +113,88 @@ text_descriptions_longclip = {
|
|
| 113 |
|
| 114 |
@app.route('/')
|
| 115 |
def home():
|
| 116 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 152 |
-
|
| 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 |
-
|
| 163 |
-
|
|
|
|
|
|
|
| 164 |
|
| 165 |
-
|
| 166 |
-
bbox_width_orig = x_max_orig - x_min_orig
|
| 167 |
-
bbox_height_orig = y_max_orig - y_min_orig
|
| 168 |
|
| 169 |
-
|
| 170 |
-
|
| 171 |
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
|
| 175 |
-
|
| 176 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
|
| 178 |
-
|
| 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 |
-
|
| 185 |
-
|
|
|
|
| 186 |
|
| 187 |
-
|
|
|
|
|
|
|
|
|
|
| 188 |
|
| 189 |
-
|
| 190 |
-
|
|
|
|
|
|
|
| 191 |
|
| 192 |
-
#
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"
|
| 251 |
-
"
|
| 252 |
-
"
|
| 253 |
-
"
|
| 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: '© OpenStreetMap contributors © 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: '© 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="
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>
|