RushiMane2003 commited on
Commit
793fced
·
verified ·
1 Parent(s): 9319a8e

Upload 16 files

Browse files
.gitattributes CHANGED
@@ -33,3 +33,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ static/images/bacterial_leaf_blight.jpg filter=lfs diff=lfs merge=lfs -text
37
+ static/images/downy_mildew.jpg filter=lfs diff=lfs merge=lfs -text
app.py ADDED
@@ -0,0 +1,361 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import json
3
+ import re
4
+ import time
5
+ import logging
6
+ from datetime import datetime
7
+ from flask import Flask, render_template, request, jsonify, session, make_response
8
+ from google import genai
9
+ from dotenv import load_dotenv
10
+ from cachelib import SimpleCache
11
+
12
+ # Configure logging
13
+ logging.basicConfig(level=logging.INFO)
14
+ logger = logging.getLogger(__name__)
15
+
16
+ # Load environment variables
17
+ load_dotenv()
18
+
19
+ # Configure the Gemini API - get key from env or Hugging Face Spaces secrets
20
+ api_key = os.getenv("GEMINI_API_KEY")
21
+ if not api_key:
22
+ logger.warning("GEMINI_API_KEY not found in environment variables. Make sure to set it in Hugging Face Spaces secrets.")
23
+
24
+ client = genai.Client(api_key=api_key)
25
+
26
+ # Initialize cache (7 days timeout)
27
+ cache = SimpleCache(threshold=50, default_timeout=60*60*24*7)
28
+
29
+ app = Flask(__name__)
30
+ # Use secret key from environment or a default for development
31
+ app.secret_key = os.getenv("SECRET_KEY", "dev_secret_key")
32
+ # Set session cookie settings to be more persistent
33
+ app.config['SESSION_COOKIE_SECURE'] = False # Set to True in HTTPS environments
34
+ app.config['SESSION_COOKIE_HTTPONLY'] = True
35
+ app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'
36
+ app.config['PERMANENT_SESSION_LIFETIME'] = 60*60*24*7 # 7 days
37
+
38
+ # Supported languages
39
+ LANGUAGES = {
40
+ "en": "English",
41
+ "hi": "हिंदी (Hindi)",
42
+ "bn": "বাংলা (Bengali)",
43
+ "te": "తెలుగు (Telugu)",
44
+ "mr": "मराठी (Marathi)",
45
+ "ta": "தமிழ் (Tamil)",
46
+ "gu": "ગુજરાતી (Gujarati)",
47
+ "ur": "اردو (Urdu)",
48
+ "kn": "ಕನ್ನಡ (Kannada)",
49
+ "or": "ଓଡ଼ିଆ (Odia)",
50
+ "ml": "മലയാളം (Malayalam)"
51
+ }
52
+
53
+ # List of pests and diseases
54
+ PESTS_DISEASES = [
55
+ {
56
+ "id": 1,
57
+ "name": "Fall Armyworm",
58
+ "type": "pest",
59
+ "crop": "Maize, Sorghum",
60
+ "image_url": "static/images/fall_armyworm.jpg"
61
+ },
62
+ {
63
+ "id": 2,
64
+ "name": "Rice Blast",
65
+ "type": "disease",
66
+ "crop": "Rice",
67
+ "image_url": "static/images/rice_blast.jpg"
68
+ },
69
+ {
70
+ "id": 3,
71
+ "name": "Aphids",
72
+ "type": "pest",
73
+ "crop": "Various crops",
74
+ "image_url": "static/images/aphids.jpg"
75
+ },
76
+ {
77
+ "id": 4,
78
+ "name": "Powdery Mildew",
79
+ "type": "disease",
80
+ "crop": "Wheat, Vegetables",
81
+ "image_url": "static/images/powdery_mildew.jpg"
82
+ },
83
+ {
84
+ "id": 5,
85
+ "name": "Stem Borer",
86
+ "type": "pest",
87
+ "crop": "Rice, Sugarcane",
88
+ "image_url": "static/images/stem_borer.jpg"
89
+ },
90
+ {
91
+ "id": 6,
92
+ "name": "Yellow Mosaic Virus",
93
+ "type": "disease",
94
+ "crop": "Pulses",
95
+ "image_url": "static/images/yellow_mosaic.jpg"
96
+ },
97
+ {
98
+ "id": 7,
99
+ "name": "Brown Planthopper",
100
+ "type": "pest",
101
+ "crop": "Rice",
102
+ "image_url": "static/images/brown_planthopper.jpg"
103
+ },
104
+ {
105
+ "id": 8,
106
+ "name": "Bacterial Leaf Blight",
107
+ "type": "disease",
108
+ "crop": "Rice",
109
+ "image_url": "static/images/bacterial_leaf_blight.jpg"
110
+ },
111
+ {
112
+ "id": 9,
113
+ "name": "Jassids",
114
+ "type": "pest",
115
+ "crop": "Cotton, Maize",
116
+ "image_url": "static/images/jassids.jpg"
117
+ },
118
+ {
119
+ "id": 10,
120
+ "name": "Downy Mildew",
121
+ "type": "disease",
122
+ "crop": "Grapes, Onion",
123
+ "image_url": "static/images/downy_mildew.jpg"
124
+ },
125
+ {
126
+ "id": 11,
127
+ "name": "Whitefly",
128
+ "type": "pest",
129
+ "crop": "Tomato, Cotton",
130
+ "image_url": "static/images/whitefly.jpg"
131
+ },
132
+ {
133
+ "id": 12,
134
+ "name": "Fusarium Wilt",
135
+ "type": "disease",
136
+ "crop": "Banana, Tomato",
137
+ "image_url": "static/images/fusarium_wilt.jpg"
138
+ }
139
+ ]
140
+
141
+ @app.route('/')
142
+ def index():
143
+ # Set default language if not set
144
+ if 'language' not in session:
145
+ session['language'] = 'en'
146
+
147
+ # Make session persistent
148
+ session.permanent = True
149
+
150
+ return render_template('index.html',
151
+ pests_diseases=PESTS_DISEASES,
152
+ languages=LANGUAGES,
153
+ current_language=session['language'])
154
+
155
+ @app.route('/set_language', methods=['POST'])
156
+ def set_language():
157
+ language = request.form.get('language')
158
+ if language in LANGUAGES:
159
+ session.permanent = True
160
+ session['language'] = language
161
+ # Log the language change
162
+ logger.info(f"Language changed to {language}")
163
+
164
+ # Create a response with success message
165
+ response = make_response(jsonify({"success": True}))
166
+
167
+ # Set a cookie to ensure the language persists even if session fails
168
+ response.set_cookie('user_language', language, max_age=60*60*24*30) # 30 days
169
+
170
+ return response
171
+ return jsonify({"success": False}), 400
172
+
173
+ def extract_json_from_response(content):
174
+ """Extract and parse JSON from API response."""
175
+ try:
176
+ # Try direct JSON parsing first
177
+ return json.loads(content)
178
+ except json.JSONDecodeError:
179
+ # If direct parsing fails, try to extract JSON from markdown code blocks
180
+ json_match = re.search(r'```json(.*?)```', content, re.DOTALL)
181
+ if json_match:
182
+ json_str = json_match.group(1).strip()
183
+ else:
184
+ json_str = content
185
+
186
+ # Clean up any potential markdown or text
187
+ json_str = json_str.replace('```json', '').replace('```', '')
188
+
189
+ try:
190
+ return json.loads(json_str)
191
+ except json.JSONDecodeError as e:
192
+ logger.error(f"JSON parsing error: {str(e)}")
193
+ raise ValueError(f"Failed to parse response")
194
+
195
+ @app.route('/get_details/<int:pest_id>')
196
+ def get_details(pest_id):
197
+ # Get current language from session or cookie fallback
198
+ language = session.get('language', request.cookies.get('user_language', 'en'))
199
+
200
+ # Find the pest/disease by ID
201
+ pest_disease = next((item for item in PESTS_DISEASES if item["id"] == pest_id), None)
202
+
203
+ if not pest_disease:
204
+ return jsonify({"error": "Not found"}), 404
205
+
206
+ # Check cache first - cache key includes language
207
+ cache_key = f"pest_disease_{pest_id}_{language}"
208
+ cached_result = cache.get(cache_key)
209
+
210
+ if cached_result:
211
+ logger.info(f"Cache hit for pest_id {pest_id} in {language}")
212
+ return jsonify(cached_result)
213
+
214
+ logger.info(f"Cache miss for pest_id {pest_id} in {language}, fetching from API")
215
+
216
+ # If no API key is set, return a fake response for testing
217
+ if not api_key:
218
+ logger.warning("No API key set, returning placeholder content")
219
+ return jsonify({
220
+ **pest_disease,
221
+ "details": {
222
+ "description": {"title": "Description", "text": "API key not configured. Please set GEMINI_API_KEY in Hugging Face Spaces secrets."},
223
+ "lifecycle": {"title": "Lifecycle", "text": "Information not available without API key."},
224
+ "symptoms": {"title": "Symptoms", "text": "Information not available without API key."},
225
+ "impact": {"title": "Impact", "text": "Information not available without API key."},
226
+ "management": {"title": "Management", "text": "Information not available without API key."},
227
+ "prevention": {"title": "Prevention", "text": "Information not available without API key."}
228
+ },
229
+ "language": language,
230
+ "cached_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
231
+ })
232
+
233
+ # Generate prompt with language instruction
234
+ lang_instructions = {
235
+ "en": "Respond in English",
236
+ "hi": "हिंदी में जवाब दें (Respond in Hindi)",
237
+ "bn": "বাংলায় উত্তর দিন (Respond in Bengali)",
238
+ "te": "తెలుగులో సమాధానం ఇవ్వండి (Respond in Telugu)",
239
+ "mr": "मराठीत उत्तर द्या (Respond in Marathi)",
240
+ "ta": "தமிழில் பதிலளிக்கவும் (Respond in Tamil)",
241
+ "gu": "ગુજરાતીમાં જવાબ આપો (Respond in Gujarati)",
242
+ "ur": "اردو میں جواب دیں (Respond in Urdu)",
243
+ "kn": "ಕನ್ನಡದಲ್ಲಿ ಉತ್ತರಿಸಿ (Respond in Kannada)",
244
+ "or": "ଓଡ଼ିଆରେ ଉତ୍ତର ଦିଅନ୍ତୁ (Respond in Odia)",
245
+ "ml": "മലയാളത്തിൽ മറുപടി നൽകുക (Respond in Malayalam)"
246
+ }
247
+
248
+ prompt = f"""
249
+ {lang_instructions.get(language, "Respond in English")}
250
+
251
+ Provide detailed information about the agricultural {pest_disease['type']} known as {pest_disease['name']}
252
+ in the context of Indian agriculture, especially affecting {pest_disease['crop']}.
253
+
254
+ Include the following sections:
255
+ 1. Description and identification
256
+ 2. Lifecycle and spread
257
+ 3. Damage symptoms
258
+ 4. Economic impact
259
+ 5. Management and control measures (both organic and chemical)
260
+ 6. Preventive measures
261
+
262
+ Format the response as JSON with these keys: "description", "lifecycle", "symptoms",
263
+ "impact", "management", "prevention".
264
+
265
+ Each key should contain an object with "title" and "text" fields.
266
+
267
+ For example:
268
+ {{
269
+ "description": {{"title": "Description", "text": "Detailed description..."}},
270
+ "lifecycle": {{"title": "Lifecycle", "text": "Information about lifecycle..."}},
271
+ ...
272
+ }}
273
+
274
+ Ensure the response is strictly valid JSON.
275
+ """
276
+
277
+ try:
278
+ # Call AI API with retry logic
279
+ model = 'gemini-1.5-flash'
280
+ max_retries = 3
281
+ retry_delay = 2
282
+
283
+ for attempt in range(max_retries):
284
+ try:
285
+ response = client.models.generate_content(model=model, contents=prompt)
286
+ break
287
+ except Exception as e:
288
+ if attempt < max_retries - 1:
289
+ logger.warning(f"API call attempt {attempt+1} failed. Retrying...")
290
+ time.sleep(retry_delay)
291
+ retry_delay *= 2
292
+ else:
293
+ logger.error(f"All API call attempts failed for pest_id {pest_id}")
294
+ raise e
295
+
296
+ # Parse the response
297
+ detailed_info = extract_json_from_response(response.text)
298
+
299
+ # Validate the response structure
300
+ required_keys = ["description", "lifecycle", "symptoms", "impact", "management", "prevention"]
301
+ for key in required_keys:
302
+ if key not in detailed_info:
303
+ detailed_info[key] = {"title": f"{key.capitalize()}", "text": "Information not available."}
304
+ elif not isinstance(detailed_info[key], dict):
305
+ detailed_info[key] = {"title": f"{key.capitalize()}", "text": str(detailed_info[key])}
306
+ elif "text" not in detailed_info[key]:
307
+ detailed_info[key]["text"] = "Information not available."
308
+
309
+ # Add timestamp and language info
310
+ result = {
311
+ **pest_disease,
312
+ "details": detailed_info,
313
+ "language": language,
314
+ "cached_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
315
+ }
316
+
317
+ # Cache the result
318
+ cache.set(cache_key, result)
319
+ logger.info(f"Successfully cached data for pest_id {pest_id} in {language}")
320
+
321
+ return jsonify(result)
322
+
323
+ except Exception as e:
324
+ logger.error(f"Error: {str(e)}")
325
+ return jsonify({
326
+ "error": "Failed to fetch information",
327
+ "message": "Please try again later."
328
+ }), 500
329
+
330
+ @app.route('/api/pests')
331
+ def get_pests_list():
332
+ return jsonify(PESTS_DISEASES)
333
+
334
+ @app.route('/api/clear-cache/<int:pest_id>', methods=['POST'])
335
+ def clear_specific_cache(pest_id):
336
+ for lang in LANGUAGES.keys():
337
+ cache_key = f"pest_disease_{pest_id}_{lang}"
338
+ cache.delete(cache_key)
339
+ return jsonify({"success": True, "message": f"Cache cleared for pest ID {pest_id}"})
340
+
341
+ # Add a diagnostics endpoint to help debug session issues
342
+ @app.route('/debug/session')
343
+ def debug_session():
344
+ # Only enable in development
345
+ if os.getenv("FLASK_ENV") == "production":
346
+ return jsonify({"error": "Not available in production"}), 403
347
+
348
+ return jsonify({
349
+ "session_data": dict(session),
350
+ "cookies": dict(request.cookies),
351
+ "session_cookie_name": app.session_cookie_name,
352
+ "session_cookie_secure": app.config.get('SESSION_COOKIE_SECURE'),
353
+ "session_cookie_httponly": app.config.get('SESSION_COOKIE_HTTPONLY'),
354
+ "session_cookie_samesite": app.config.get('SESSION_COOKIE_SAMESITE'),
355
+ "permanent_session_lifetime": str(app.config.get('PERMANENT_SESSION_LIFETIME'))
356
+ })
357
+
358
+ if __name__ == '__main__':
359
+ # Use PORT environment variable if available (for Hugging Face Spaces)
360
+ port = int(os.environ.get("PORT", 7860))
361
+ app.run(host="0.0.0.0", port=port)
static/images/aphids.jpg ADDED
static/images/bacterial_leaf_blight.jpg ADDED

Git LFS Details

  • SHA256: 3c66338c4d8b0f878e3caa5ebe297b9e5d174fa25983b04f4fdbaa92bfc369fb
  • Pointer size: 131 Bytes
  • Size of remote file: 132 kB
static/images/brown_planthopper.jpg ADDED
static/images/downy_mildew.jpg ADDED

Git LFS Details

  • SHA256: 90990c6f2a60058b9a0619735ad079ee03dcee72724eb2dffb3b784ce375815b
  • Pointer size: 132 Bytes
  • Size of remote file: 1.03 MB
static/images/fall_armyworm.jpg ADDED
static/images/fusarium_wilt.jpg ADDED
static/images/jassids.jpg ADDED
static/images/powdery_mildew.jpg ADDED
static/images/rice_blast.jpg ADDED
static/images/stem_borer.jpg ADDED
static/images/whitefly.jpg ADDED
static/images/yellow_mosaic.jpg ADDED
templates/index.html ADDED
@@ -0,0 +1,529 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!-- templates/index.html -->
2
+ <!DOCTYPE html>
3
+ <html lang="{{ current_language }}">
4
+ <head>
5
+ <meta charset="UTF-8">
6
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
7
+ <title>Agricultural Pests & Diseases in India</title>
8
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
9
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
10
+ <style>
11
+ :root {
12
+ --primary-color: #38b000;
13
+ --secondary-color: #007bff;
14
+ --accent-color: #fb8500;
15
+ --light-bg: #f8f9fa;
16
+ --dark-text: #212529;
17
+ --pest-color: #dc3545;
18
+ --disease-color: #6f42c1;
19
+ }
20
+
21
+ body {
22
+ background-color: var(--light-bg);
23
+ padding: 20px;
24
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
25
+ }
26
+
27
+ .page-header {
28
+ background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
29
+ color: white;
30
+ padding: 2rem 0;
31
+ border-radius: 10px;
32
+ margin-bottom: 2rem;
33
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
34
+ }
35
+
36
+ .language-selector select {
37
+ background-color: rgba(255, 255, 255, 0.2);
38
+ color: white;
39
+ border: 1px solid rgba(255, 255, 255, 0.3);
40
+ }
41
+
42
+ .language-selector select option {
43
+ background-color: white;
44
+ color: var(--dark-text);
45
+ }
46
+
47
+ .filter-buttons {
48
+ margin-bottom: 20px;
49
+ }
50
+
51
+ .filter-btn {
52
+ margin-right: 10px;
53
+ border-radius: 20px;
54
+ font-weight: 500;
55
+ padding: 8px 16px;
56
+ transition: all 0.3s ease;
57
+ }
58
+
59
+ .filter-btn:hover {
60
+ transform: translateY(-2px);
61
+ }
62
+
63
+ .filter-btn.active {
64
+ box-shadow: 0 4px 8px rgba(0,0,0,0.15);
65
+ }
66
+
67
+ .card {
68
+ transition: all 0.3s ease;
69
+ height: 100%;
70
+ cursor: pointer;
71
+ border-radius: 10px;
72
+ border: none;
73
+ box-shadow: 0 4px 8px rgba(0,0,0,0.05);
74
+ overflow: hidden;
75
+ }
76
+
77
+ .card:hover {
78
+ transform: translateY(-8px);
79
+ box-shadow: 0 12px 24px rgba(0,0,0,0.15);
80
+ }
81
+
82
+ .card-img-top {
83
+ height: 200px;
84
+ object-fit: cover;
85
+ transition: all 0.5s ease;
86
+ }
87
+
88
+ .card:hover .card-img-top {
89
+ transform: scale(1.05);
90
+ }
91
+
92
+ .card-body {
93
+ padding: 1.5rem;
94
+ }
95
+
96
+ .card-title {
97
+ font-weight: 600;
98
+ margin-bottom: 10px;
99
+ }
100
+
101
+ .loading {
102
+ display: none;
103
+ text-align: center;
104
+ padding: 20px;
105
+ }
106
+
107
+ .modal-body {
108
+ max-height: 70vh;
109
+ overflow-y: auto;
110
+ }
111
+
112
+ .badge.pest {
113
+ background-color: var(--pest-color);
114
+ padding: 0.5em 0.8em;
115
+ font-size: 0.8rem;
116
+ }
117
+
118
+ .badge.disease {
119
+ background-color: var(--disease-color);
120
+ padding: 0.5em 0.8em;
121
+ font-size: 0.8rem;
122
+ }
123
+
124
+ .modal-content {
125
+ border-radius: 15px;
126
+ border: none;
127
+ box-shadow: 0 10px 30px rgba(0,0,0,0.2);
128
+ }
129
+
130
+ .modal-header {
131
+ border-bottom: 1px solid rgba(0,0,0,0.1);
132
+ background-color: var(--light-bg);
133
+ }
134
+
135
+ .modal-title {
136
+ font-weight: 600;
137
+ }
138
+
139
+ .section-title {
140
+ margin-top: 1.5rem;
141
+ margin-bottom: 0.75rem;
142
+ font-weight: 600;
143
+ color: var(--dark-text);
144
+ border-bottom: 2px solid var(--primary-color);
145
+ padding-bottom: 0.5rem;
146
+ display: inline-block;
147
+ }
148
+
149
+ #backToTop {
150
+ position: fixed;
151
+ bottom: 20px;
152
+ right: 20px;
153
+ display: none;
154
+ background-color: var(--primary-color);
155
+ color: white;
156
+ border: none;
157
+ border-radius: 50%;
158
+ width: 50px;
159
+ height: 50px;
160
+ text-align: center;
161
+ font-size: 20px;
162
+ line-height: 50px;
163
+ cursor: pointer;
164
+ z-index: 99;
165
+ box-shadow: 0 4px 10px rgba(0,0,0,0.2);
166
+ }
167
+
168
+ .crop-badge {
169
+ background-color: #17a2b8;
170
+ color: white;
171
+ font-size: 0.8rem;
172
+ font-weight: normal;
173
+ margin-right: 4px;
174
+ }
175
+
176
+ .no-results {
177
+ text-align: center;
178
+ padding: 40px;
179
+ font-size: 1.2rem;
180
+ color: #6c757d;
181
+ }
182
+
183
+ /* Spinner */
184
+ .spinner {
185
+ width: 40px;
186
+ height: 40px;
187
+ margin: 20px auto;
188
+ border: 4px solid rgba(0, 0, 0, 0.1);
189
+ border-left-color: var(--primary-color);
190
+ border-radius: 50%;
191
+ animation: spin 1s linear infinite;
192
+ }
193
+
194
+ @keyframes spin {
195
+ 0% { transform: rotate(0deg); }
196
+ 100% { transform: rotate(360deg); }
197
+ }
198
+ </style>
199
+ </head>
200
+ <body>
201
+ <div class="container">
202
+ <header class="page-header text-center mb-4">
203
+ <h1 class="display-4">Agricultural Pests & Diseases in India</h1>
204
+ <p class="lead">Explore common agricultural threats and learn how to manage them</p>
205
+
206
+ <!-- Language selector -->
207
+ <div class="language-selector mt-3">
208
+ <form id="languageForm" class="d-flex justify-content-center align-items-center">
209
+ <label for="languageSelect" class="me-2">
210
+ <i class="fas fa-language"></i> Language:
211
+ </label>
212
+ <select id="languageSelect" class="form-select form-select-sm" style="max-width: 200px;">
213
+ {% for code, name in languages.items() %}
214
+ <option value="{{ code }}" {% if code == current_language %}selected{% endif %}>{{ name }}</option>
215
+ {% endfor %}
216
+ </select>
217
+ </form>
218
+ </div>
219
+ </header>
220
+
221
+ <!-- Filter buttons -->
222
+ <div class="filter-buttons text-center mb-4">
223
+ <button class="btn btn-outline-primary filter-btn active" data-filter="all">All Items</button>
224
+ <button class="btn btn-outline-danger filter-btn" data-filter="pest">Pests Only</button>
225
+ <button class="btn btn-outline-purple filter-btn" data-filter="disease" style="border-color: #6f42c1; color: #6f42c1;">Diseases Only</button>
226
+ <div class="input-group mt-3 w-50 mx-auto">
227
+ <span class="input-group-text"><i class="fas fa-search"></i></span>
228
+ <input type="text" class="form-control" id="searchInput" placeholder="Search by name or crop...">
229
+ </div>
230
+ </div>
231
+
232
+ <div class="row row-cols-1 row-cols-md-3 g-4" id="itemsContainer">
233
+ {% for item in pests_diseases %}
234
+ <div class="col item-card" data-type="{{ item.type }}" data-name="{{ item.name.lower() }}" data-crop="{{ item.crop.lower() }}">
235
+ <div class="card h-100" onclick="showDetails({{ item.id }})">
236
+ <div class="position-relative overflow-hidden">
237
+ <img data-src="{{ item.image_url }}" class="card-img-top lazy-load" alt="{{ item.name }}">
238
+ <span class="position-absolute top-0 end-0 m-2 badge {{ item.type }}">
239
+ {{ item.type|capitalize }}
240
+ </span>
241
+ </div>
242
+ <div class="card-body">
243
+ <h5 class="card-title">{{ item.name }}</h5>
244
+ <p class="card-text">
245
+ <strong>Affects:</strong><br>
246
+ {% for crop in item.crop.split(', ') %}
247
+ <span class="badge crop-badge">{{ crop }}</span>
248
+ {% endfor %}
249
+ </p>
250
+ </div>
251
+ </div>
252
+ </div>
253
+ {% endfor %}
254
+ </div>
255
+
256
+ <div id="noResults" class="no-results" style="display: none;">
257
+ <i class="fas fa-search fa-3x mb-3 text-muted"></i>
258
+ <p>No matching items found. Try another search term.</p>
259
+ </div>
260
+
261
+ <!-- Loading indicator -->
262
+ <div id="loading" class="loading mt-4">
263
+ <div class="spinner"></div>
264
+ <p class="mt-2">Fetching detailed information...</p>
265
+ </div>
266
+
267
+ <!-- Detail Modal -->
268
+ <div class="modal fade" id="detailModal" tabindex="-1" aria-hidden="true">
269
+ <div class="modal-dialog modal-lg">
270
+ <div class="modal-content">
271
+ <div class="modal-header">
272
+ <h5 class="modal-title" id="modalTitle"></h5>
273
+ <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
274
+ </div>
275
+ <div class="modal-body" id="modalBody">
276
+ <!-- Content will be loaded here -->
277
+ <div class="text-center p-5" id="modalLoading">
278
+ <div class="spinner"></div>
279
+ <p class="mt-3">Loading content...</p>
280
+ </div>
281
+ </div>
282
+ <div class="modal-footer">
283
+ <button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close</button>
284
+ </div>
285
+ </div>
286
+ </div>
287
+ </div>
288
+
289
+ <!-- Back to top button -->
290
+ <button id="backToTop" title="Back to top">
291
+ <i class="fas fa-chevron-up"></i>
292
+ </button>
293
+ </div>
294
+
295
+ <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/js/bootstrap.bundle.min.js"></script>
296
+ <script>
297
+ // Initialize modal
298
+ const detailModal = new bootstrap.Modal(document.getElementById('detailModal'));
299
+ let loadedDetails = {};
300
+
301
+ // Language selector
302
+ document.getElementById('languageSelect').addEventListener('change', function() {
303
+ const selectedLanguage = this.value;
304
+
305
+ // Show loading spinner
306
+ const loadingElement = document.createElement('div');
307
+ loadingElement.id = 'pageLoading';
308
+ loadingElement.className = 'position-fixed top-0 start-0 w-100 h-100 d-flex justify-content-center align-items-center bg-white bg-opacity-75';
309
+ loadingElement.style.zIndex = '9999';
310
+ loadingElement.innerHTML = '<div class="spinner"></div><p class="mt-3">Changing language...</p>';
311
+ document.body.appendChild(loadingElement);
312
+
313
+ // Send request to change language
314
+ fetch('/set_language', {
315
+ method: 'POST',
316
+ headers: {
317
+ 'Content-Type': 'application/x-www-form-urlencoded',
318
+ },
319
+ body: `language=${selectedLanguage}`,
320
+ credentials: 'same-origin' // Ensure cookies are sent with the request
321
+ })
322
+ .then(response => response.json())
323
+ .then(data => {
324
+ if (data.success) {
325
+ // Clear loaded details cache
326
+ loadedDetails = {};
327
+
328
+ // Store language in localStorage as a fallback
329
+ localStorage.setItem('preferredLanguage', selectedLanguage);
330
+
331
+ // Refresh the page
332
+ window.location.reload();
333
+ }
334
+ })
335
+ .catch(error => {
336
+ console.error('Error:', error);
337
+ document.body.removeChild(loadingElement);
338
+ alert('Error changing language. Please try again.');
339
+ });
340
+ });
341
+
342
+ // Check if we need to restore language preference from localStorage
343
+ document.addEventListener('DOMContentLoaded', function() {
344
+ const storedLanguage = localStorage.getItem('preferredLanguage');
345
+ const currentLanguage = document.getElementById('languageSelect').value;
346
+
347
+ if (storedLanguage && storedLanguage !== currentLanguage) {
348
+ // If the stored language differs from the current one, set it
349
+ document.getElementById('languageSelect').value = storedLanguage;
350
+ // Trigger the change event
351
+ document.getElementById('languageSelect').dispatchEvent(new Event('change'));
352
+ }
353
+ });
354
+
355
+ // Lazy load images
356
+ document.addEventListener("DOMContentLoaded", function() {
357
+ const lazyImages = document.querySelectorAll(".lazy-load");
358
+
359
+ if ("IntersectionObserver" in window) {
360
+ const imageObserver = new IntersectionObserver(function(entries, observer) {
361
+ entries.forEach(function(entry) {
362
+ if (entry.isIntersecting) {
363
+ const img = entry.target;
364
+ img.src = img.dataset.src;
365
+ img.classList.remove("lazy-load");
366
+ imageObserver.unobserve(img);
367
+ }
368
+ });
369
+ });
370
+
371
+ lazyImages.forEach(function(image) {
372
+ imageObserver.observe(image);
373
+ });
374
+ } else {
375
+ // Fallback for browsers without intersection observer
376
+ lazyImages.forEach(function(img) {
377
+ img.src = img.dataset.src;
378
+ });
379
+ }
380
+ });
381
+
382
+ // Filter functionality
383
+ document.querySelectorAll('.filter-btn').forEach(button => {
384
+ button.addEventListener('click', function() {
385
+ document.querySelectorAll('.filter-btn').forEach(btn => {
386
+ btn.classList.remove('active');
387
+ });
388
+
389
+ this.classList.add('active');
390
+
391
+ const filterValue = this.getAttribute('data-filter');
392
+ filterItems(filterValue, document.getElementById('searchInput').value);
393
+ });
394
+ });
395
+
396
+ // Search functionality
397
+ document.getElementById('searchInput').addEventListener('input', function() {
398
+ const activeFilter = document.querySelector('.filter-btn.active').getAttribute('data-filter');
399
+ filterItems(activeFilter, this.value);
400
+ });
401
+
402
+ function filterItems(typeFilter, searchTerm) {
403
+ searchTerm = searchTerm.toLowerCase();
404
+ let visibleCount = 0;
405
+
406
+ document.querySelectorAll('.item-card').forEach(item => {
407
+ const type = item.getAttribute('data-type');
408
+ const name = item.getAttribute('data-name');
409
+ const crop = item.getAttribute('data-crop');
410
+
411
+ const typeMatch = typeFilter === 'all' || type === typeFilter;
412
+ const searchMatch = !searchTerm ||
413
+ name.includes(searchTerm) ||
414
+ crop.includes(searchTerm);
415
+
416
+ if (typeMatch && searchMatch) {
417
+ item.style.display = 'block';
418
+ visibleCount++;
419
+ } else {
420
+ item.style.display = 'none';
421
+ }
422
+ });
423
+
424
+ // Show/hide no results message
425
+ document.getElementById('noResults').style.display = visibleCount === 0 ? 'block' : 'none';
426
+ }
427
+
428
+ // Function to show details
429
+ function showDetails(id) {
430
+ // Clear previous content and show loading
431
+ document.getElementById('modalTitle').textContent = "";
432
+ document.getElementById('modalBody').innerHTML = `
433
+ <div class="text-center p-5" id="modalLoading">
434
+ <div class="spinner"></div>
435
+ <p class="mt-3">Loading content...</p>
436
+ </div>
437
+ `;
438
+
439
+ // Show modal immediately with loading indicator
440
+ detailModal.show();
441
+
442
+ // Check if we already have the details cached
443
+ if (loadedDetails[id]) {
444
+ displayDetails(loadedDetails[id]);
445
+ return;
446
+ }
447
+
448
+ // Fetch details from the backend
449
+ fetch(`/get_details/${id}`, {
450
+ credentials: 'same-origin' // Ensure cookies are sent with the request
451
+ })
452
+ .then(response => {
453
+ if (!response.ok) {
454
+ throw new Error('Network response was not ok');
455
+ }
456
+ return response.json();
457
+ })
458
+ .then(data => {
459
+ // Cache the result
460
+ loadedDetails[id] = data;
461
+ displayDetails(data);
462
+ })
463
+ .catch(error => {
464
+ console.error('Error:', error);
465
+ document.getElementById('modalBody').innerHTML = `
466
+ <div class="alert alert-danger" role="alert">
467
+ <i class="fas fa-exclamation-circle"></i>
468
+ Error loading information. Please try again.
469
+ </div>
470
+ `;
471
+ });
472
+ }
473
+
474
+ function displayDetails(data) {
475
+ // Update modal title
476
+ document.getElementById('modalTitle').textContent = data.name;
477
+
478
+ // Format the details
479
+ const details = data.details;
480
+ let content = `
481
+ <div class="text-center mb-4">
482
+ <img src="${data.image_url}" alt="${data.name}" class="img-fluid rounded" style="max-height: 300px;">
483
+ <div class="mt-2">
484
+ <span class="badge ${data.type} mb-2">${data.type.charAt(0).toUpperCase() + data.type.slice(1)}</span>
485
+ <div>
486
+ <strong>Affects:</strong>
487
+ ${data.crop.split(', ').map(crop => `<span class="badge crop-badge">${crop}</span>`).join(' ')}
488
+ </div>
489
+ </div>
490
+ </div>
491
+ `;
492
+
493
+ // Add each section
494
+ const sections = [
495
+ {key: 'description', icon: 'info-circle', title: details.description.title || 'Description and Identification'},
496
+ {key: 'lifecycle', icon: 'sync', title: details.lifecycle.title || 'Lifecycle and Spread'},
497
+ {key: 'symptoms', icon: 'exclamation-triangle', title: details.symptoms.title || 'Damage Symptoms'},
498
+ {key: 'impact', icon: 'chart-line', title: details.impact.title || 'Economic Impact'},
499
+ {key: 'management', icon: 'tasks', title: details.management.title || 'Management and Control'},
500
+ {key: 'prevention', icon: 'shield-alt', title: details.prevention.title || 'Prevention'}
501
+ ];
502
+
503
+ sections.forEach(section => {
504
+ if (details[section.key] && details[section.key].text) {
505
+ content += `
506
+ <h4 class="section-title"><i class="fas fa-${section.icon} me-2"></i>${section.title}</h4>
507
+ <p>${details[section.key].text}</p>
508
+ `;
509
+ }
510
+ });
511
+
512
+ document.getElementById('modalBody').innerHTML = content;
513
+ }
514
+
515
+ // Back to top button functionality
516
+ window.onscroll = function() {
517
+ if (document.body.scrollTop > 20 || document.documentElement.scrollTop > 20) {
518
+ document.getElementById("backToTop").style.display = "block";
519
+ } else {
520
+ document.getElementById("backToTop").style.display = "none";
521
+ }
522
+ };
523
+
524
+ document.getElementById("backToTop").addEventListener("click", function() {
525
+ window.scrollTo({top: 0, behavior: 'smooth'});
526
+ });
527
+ </script>
528
+ </body>
529
+ </html>
templates/results.html ADDED
@@ -0,0 +1,50 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>{{ market.market_name }} Market Details</title>
7
+ <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0-alpha1/dist/css/bootstrap.min.css" rel="stylesheet">
8
+ <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
9
+ <style>
10
+ #map-container {
11
+ height: 500px;
12
+ width: 100%;
13
+ margin-top: 20px;
14
+ }
15
+ .insights-container {
16
+ background-color: #f8f9fa;
17
+ padding: 15px;
18
+ border-radius: 5px;
19
+ margin-top: 20px;
20
+ }
21
+ .loader {
22
+ display: none;
23
+ text-align: center;
24
+ margin: 20px 0;
25
+ }
26
+ </style>
27
+ </head>
28
+ <body>
29
+ <div class="container">
30
+ <h1>{{ market.market_name }} Market Details</h1>
31
+ <p><strong>Market ID:</strong> {{ market.market_id }}</p>
32
+ <p><strong>Market Name:</strong> {{ market.market_name }}</p>
33
+ <p><strong>Address:</strong> {{ market.address }}</p>
34
+ <p><strong>City:</strong> {{ market.city }}</p>
35
+ <p><strong>State:</strong> {{ market.state }}</p>
36
+ <p><strong>Zip Code:</strong> {{ market.zip_code }}</p>
37
+ <p><strong>Phone Number:</strong> {{ market.phone_number }}</p>
38
+ <p><strong>Website:</strong> <a href="{{ market.website }}" target="_blank">{{ market.website }}</a></p>
39
+ <p><strong>Opening Hours:</strong> {{ market.opening_hours }}</p>
40
+ <p><strong>Latitude:</strong> {{ market.latitude }}</p>
41
+ <p><strong>Longitude:</strong> {{ market.longitude }}</p>
42
+ <p><strong>Top Commodities:</strong></p>
43
+ <ul>
44
+ {% for commodity in market.top_commodities %}
45
+ <li>{{ commodity.commodity }} - Avg Price: ₹{{ commodity.modal_price }}, Min Price: ₹{{ commodity.min_price }}, Max Price: ₹{{ commodity.max_price }}</li>
46
+ {% endfor %}
47
+ </ul>
48
+ </div>
49
+ </body>
50
+ </html>
templates/style.css ADDED
@@ -0,0 +1,187 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ :root {
2
+ --primary-color: #38b000;
3
+ --secondary-color: #007bff;
4
+ --accent-color: #fb8500;
5
+ --light-bg: #f8f9fa;
6
+ --dark-text: #212529;
7
+ --pest-color: #dc3545;
8
+ --disease-color: #6f42c1;
9
+ }
10
+
11
+ body {
12
+ background-color: var(--light-bg);
13
+ padding: 20px;
14
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
15
+ }
16
+
17
+ .page-header {
18
+ background: linear-gradient(135deg, var(--primary-color), var(--secondary-color));
19
+ color: white;
20
+ padding: 2rem 0;
21
+ border-radius: 10px;
22
+ margin-bottom: 2rem;
23
+ box-shadow: 0 4px 12px rgba(0,0,0,0.1);
24
+ }
25
+
26
+ .language-selector select {
27
+ background-color: rgba(255, 255, 255, 0.2);
28
+ color: white;
29
+ border: 1px solid rgba(255, 255, 255, 0.3);
30
+ }
31
+
32
+ .language-selector select option {
33
+ background-color: white;
34
+ color: var(--dark-text);
35
+ }
36
+
37
+ .filter-buttons {
38
+ margin-bottom: 20px;
39
+ }
40
+
41
+ .filter-btn {
42
+ margin-right: 10px;
43
+ border-radius: 20px;
44
+ font-weight: 500;
45
+ padding: 8px 16px;
46
+ transition: all 0.3s ease;
47
+ }
48
+
49
+ .filter-btn:hover {
50
+ transform: translateY(-2px);
51
+ }
52
+
53
+ .filter-btn.active {
54
+ box-shadow: 0 4px 8px rgba(0,0,0,0.15);
55
+ }
56
+
57
+ .card {
58
+ transition: all 0.3s ease;
59
+ height: 100%;
60
+ cursor: pointer;
61
+ border-radius: 10px;
62
+ border: none;
63
+ box-shadow: 0 4px 8px rgba(0,0,0,0.05);
64
+ overflow: hidden;
65
+ }
66
+
67
+ .card:hover {
68
+ transform: translateY(-8px);
69
+ box-shadow: 0 12px 24px rgba(0,0,0,0.15);
70
+ }
71
+
72
+ .card-img-top {
73
+ height: 200px;
74
+ object-fit: cover;
75
+ transition: all 0.5s ease;
76
+ }
77
+
78
+ .card:hover .card-img-top {
79
+ transform: scale(1.05);
80
+ }
81
+
82
+ .card-body {
83
+ padding: 1.5rem;
84
+ }
85
+
86
+ .card-title {
87
+ font-weight: 600;
88
+ margin-bottom: 10px;
89
+ }
90
+
91
+ .loading {
92
+ display: none;
93
+ text-align: center;
94
+ padding: 20px;
95
+ }
96
+
97
+ .modal-body {
98
+ max-height: 70vh;
99
+ overflow-y: auto;
100
+ }
101
+
102
+ .badge.pest {
103
+ background-color: var(--pest-color);
104
+ padding: 0.5em 0.8em;
105
+ font-size: 0.8rem;
106
+ }
107
+
108
+ .badge.disease {
109
+ background-color: var(--disease-color);
110
+ padding: 0.5em 0.8em;
111
+ font-size: 0.8rem;
112
+ }
113
+
114
+ .modal-content {
115
+ border-radius: 15px;
116
+ border: none;
117
+ box-shadow: 0 10px 30px rgba(0,0,0,0.2);
118
+ }
119
+
120
+ .modal-header {
121
+ border-bottom: 1px solid rgba(0,0,0,0.1);
122
+ background-color: var(--light-bg);
123
+ }
124
+
125
+ .modal-title {
126
+ font-weight: 600;
127
+ }
128
+
129
+ .section-title {
130
+ margin-top: 1.5rem;
131
+ margin-bottom: 0.75rem;
132
+ font-weight: 600;
133
+ color: var(--dark-text);
134
+ border-bottom: 2px solid var(--primary-color);
135
+ padding-bottom: 0.5rem;
136
+ display: inline-block;
137
+ }
138
+
139
+ #backToTop {
140
+ position: fixed;
141
+ bottom: 20px;
142
+ right: 20px;
143
+ display: none;
144
+ background-color: var(--primary-color);
145
+ color: white;
146
+ border: none;
147
+ border-radius: 50%;
148
+ width: 50px;
149
+ height: 50px;
150
+ text-align: center;
151
+ font-size: 20px;
152
+ line-height: 50px;
153
+ cursor: pointer;
154
+ z-index: 99;
155
+ box-shadow: 0 4px 10px rgba(0,0,0,0.2);
156
+ }
157
+
158
+ .crop-badge {
159
+ background-color: #17a2b8;
160
+ color: white;
161
+ font-size: 0.8rem;
162
+ font-weight: normal;
163
+ margin-right: 4px;
164
+ }
165
+
166
+ .no-results {
167
+ text-align: center;
168
+ padding: 40px;
169
+ font-size: 1.2rem;
170
+ color: #6c757d;
171
+ }
172
+
173
+ /* Spinner */
174
+ .spinner {
175
+ width: 40px;
176
+ height: 40px;
177
+ margin: 20px auto;
178
+ border: 4px solid rgba(0, 0, 0, 0.1);
179
+ border-left-color: var(--primary-color);
180
+ border-radius: 50%;
181
+ animation: spin 1s linear infinite;
182
+ }
183
+
184
+ @keyframes spin {
185
+ 0% { transform: rotate(0deg); }
186
+ 100% { transform: rotate(360deg); }
187
+ }