sikeaditya commited on
Commit
412d9ff
·
verified ·
1 Parent(s): aa81518

Upload 20 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
Dockerfile ADDED
@@ -0,0 +1,17 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.9-slim
2
+
3
+ WORKDIR /app
4
+
5
+ COPY requirements.txt .
6
+ RUN pip install --no-cache-dir -r requirements.txt
7
+
8
+ COPY . .
9
+
10
+ # Create directory structure
11
+ RUN mkdir -p static/images
12
+
13
+ # Expose the port the app runs on
14
+ EXPOSE 7860
15
+
16
+ # Command to run the app
17
+ CMD ["gunicorn", "--bind", "0.0.0.0:7860", "app:app"]
README.md CHANGED
@@ -1,10 +1,26 @@
1
- ---
2
- title: PestPedia
3
- emoji: 💻
4
- colorFrom: purple
5
- colorTo: green
6
- sdk: docker
7
- pinned: false
8
- ---
9
-
10
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Agricultural Pests & Diseases in India
2
+
3
+ A multilingual web application that provides information about common agricultural pests and diseases in India, their lifecycle, symptoms, and management strategies.
4
+
5
+ ## Features
6
+
7
+ - Information about common agricultural pests and diseases affecting crops in India
8
+ - Multilingual support for 11 Indian languages
9
+ - Search and filter functionality
10
+ - Detailed information for each pest/disease
11
+ - Responsive design for both desktop and mobile devices
12
+
13
+ ## How to Use
14
+
15
+ 1. Browse the catalog of pests and diseases
16
+ 2. Use the filter buttons to view only pests or only diseases
17
+ 3. Search by name or affected crop
18
+ 4. Click on any card to view detailed information
19
+ 5. Change the language using the dropdown at the top
20
+
21
+ ## Technologies Used
22
+
23
+ - Flask (Python web framework)
24
+ - Google's Gemini API for multilingual content generation
25
+ - Bootstrap for responsive UI
26
+ - JavaScript for interactive elements
app.py ADDED
@@ -0,0 +1,327 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
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
+
33
+ # Supported languages
34
+ LANGUAGES = {
35
+ "en": "English",
36
+ "hi": "हिंदी (Hindi)",
37
+ "bn": "বাংলা (Bengali)",
38
+ "te": "తెలుగు (Telugu)",
39
+ "mr": "मराठी (Marathi)",
40
+ "ta": "தமிழ் (Tamil)",
41
+ "gu": "ગુજરાતી (Gujarati)",
42
+ "ur": "اردو (Urdu)",
43
+ "kn": "ಕನ್ನಡ (Kannada)",
44
+ "or": "ଓଡ଼ିଆ (Odia)",
45
+ "ml": "മലയാളം (Malayalam)"
46
+ }
47
+
48
+ # List of pests and diseases (truncated for brevity)
49
+ PESTS_DISEASES = [
50
+ {
51
+ "id": 1,
52
+ "name": "Fall Armyworm",
53
+ "type": "pest",
54
+ "crop": "Maize, Sorghum",
55
+ "image_url": "static/images/fall_armyworm.jpg"
56
+ },
57
+ {
58
+ "id": 2,
59
+ "name": "Rice Blast",
60
+ "type": "disease",
61
+ "crop": "Rice",
62
+ "image_url": "static/images/rice_blast.jpg"
63
+ },
64
+ {
65
+ "id": 3,
66
+ "name": "Aphids",
67
+ "type": "pest",
68
+ "crop": "Various crops",
69
+ "image_url": "static/images/aphids.jpg"
70
+ },
71
+ {
72
+ "id": 4,
73
+ "name": "Powdery Mildew",
74
+ "type": "disease",
75
+ "crop": "Wheat, Vegetables",
76
+ "image_url": "static/images/powdery_mildew.jpg"
77
+ },
78
+ {
79
+ "id": 5,
80
+ "name": "Stem Borer",
81
+ "type": "pest",
82
+ "crop": "Rice, Sugarcane",
83
+ "image_url": "static/images/stem_borer.jpg"
84
+ },
85
+ {
86
+ "id": 6,
87
+ "name": "Yellow Mosaic Virus",
88
+ "type": "disease",
89
+ "crop": "Pulses",
90
+ "image_url": "static/images/yellow_mosaic.jpg"
91
+ },
92
+ {
93
+ "id": 7,
94
+ "name": "Brown Planthopper",
95
+ "type": "pest",
96
+ "crop": "Rice",
97
+ "image_url": "static/images/brown_planthopper.jpg"
98
+ },
99
+ {
100
+ "id": 8,
101
+ "name": "Bacterial Leaf Blight",
102
+ "type": "disease",
103
+ "crop": "Rice",
104
+ "image_url": "static/images/bacterial_leaf_blight.jpg"
105
+ },
106
+ {
107
+ "id": 9,
108
+ "name": "Jassids",
109
+ "type": "pest",
110
+ "crop": "Cotton, Maize",
111
+ "image_url": "static/images/jassids.jpg"
112
+ },
113
+ {
114
+ "id": 10,
115
+ "name": "Downy Mildew",
116
+ "type": "disease",
117
+ "crop": "Grapes, Onion",
118
+ "image_url": "static/images/downy_mildew.jpg"
119
+ },
120
+ {
121
+ "id": 11,
122
+ "name": "Whitefly",
123
+ "type": "pest",
124
+ "crop": "Tomato, Cotton",
125
+ "image_url": "static/images/whitefly.jpg"
126
+ },
127
+ {
128
+ "id": 12,
129
+ "name": "Fusarium Wilt",
130
+ "type": "disease",
131
+ "crop": "Banana, Tomato",
132
+ "image_url": "static/images/fusarium_wilt.jpg"
133
+ }
134
+ ]
135
+
136
+ @app.route('/')
137
+ def index():
138
+ # Set default language if not set
139
+ if 'language' not in session:
140
+ session['language'] = 'en'
141
+
142
+ return render_template('index.html',
143
+ pests_diseases=PESTS_DISEASES,
144
+ languages=LANGUAGES,
145
+ current_language=session['language'])
146
+
147
+ @app.route('/set_language', methods=['POST'])
148
+ def set_language():
149
+ language = request.form.get('language')
150
+ if language in LANGUAGES:
151
+ session['language'] = language
152
+ # Clear cache when language changes
153
+ return jsonify({"success": True})
154
+ return jsonify({"success": False}), 400
155
+
156
+ def extract_json_from_response(content):
157
+ """Extract and parse JSON from API response."""
158
+ try:
159
+ # Try direct JSON parsing first
160
+ return json.loads(content)
161
+ except json.JSONDecodeError:
162
+ # If direct parsing fails, try to extract JSON from markdown code blocks
163
+ json_match = re.search(r'```json(.*?)```', content, re.DOTALL)
164
+ if json_match:
165
+ json_str = json_match.group(1).strip()
166
+ else:
167
+ json_str = content
168
+
169
+ # Clean up any potential markdown or text
170
+ json_str = json_str.replace('```json', '').replace('```', '')
171
+
172
+ try:
173
+ return json.loads(json_str)
174
+ except json.JSONDecodeError as e:
175
+ logger.error(f"JSON parsing error: {str(e)}")
176
+ raise ValueError(f"Failed to parse response")
177
+
178
+ @app.route('/get_details/<int:pest_id>')
179
+ def get_details(pest_id):
180
+ # Get current language
181
+ language = session.get('language', 'en')
182
+
183
+ # Find the pest/disease by ID
184
+ pest_disease = next((item for item in PESTS_DISEASES if item["id"] == pest_id), None)
185
+
186
+ if not pest_disease:
187
+ return jsonify({"error": "Not found"}), 404
188
+
189
+ # Check cache first - cache key includes language
190
+ cache_key = f"pest_disease_{pest_id}_{language}"
191
+ cached_result = cache.get(cache_key)
192
+
193
+ if cached_result:
194
+ logger.info(f"Cache hit for pest_id {pest_id} in {language}")
195
+ return jsonify(cached_result)
196
+
197
+ logger.info(f"Cache miss for pest_id {pest_id} in {language}, fetching from API")
198
+
199
+ # If no API key is set, return a fake response for testing
200
+ if not api_key:
201
+ logger.warning("No API key set, returning placeholder content")
202
+ return jsonify({
203
+ **pest_disease,
204
+ "details": {
205
+ "description": {"title": "Description", "text": "API key not configured. Please set GEMINI_API_KEY in Hugging Face Spaces secrets."},
206
+ "lifecycle": {"title": "Lifecycle", "text": "Information not available without API key."},
207
+ "symptoms": {"title": "Symptoms", "text": "Information not available without API key."},
208
+ "impact": {"title": "Impact", "text": "Information not available without API key."},
209
+ "management": {"title": "Management", "text": "Information not available without API key."},
210
+ "prevention": {"title": "Prevention", "text": "Information not available without API key."}
211
+ },
212
+ "language": language,
213
+ "cached_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
214
+ })
215
+
216
+ # Generate prompt with language instruction
217
+ lang_instructions = {
218
+ "en": "Respond in English",
219
+ "hi": "हिंदी में जवाब दें (Respond in Hindi)",
220
+ "bn": "বাংলায় উত্তর দিন (Respond in Bengali)",
221
+ "te": "తెలుగులో సమాధానం ఇవ్వండి (Respond in Telugu)",
222
+ "mr": "मराठीत उत्तर द्या (Respond in Marathi)",
223
+ "ta": "தமிழில் பதிலளிக்கவும் (Respond in Tamil)",
224
+ "gu": "ગુજરાતીમાં જવાબ આપો (Respond in Gujarati)",
225
+ "ur": "اردو میں جواب دیں (Respond in Urdu)",
226
+ "kn": "ಕನ್ನಡದಲ್ಲಿ ಉತ್ತರಿಸಿ (Respond in Kannada)",
227
+ "or": "ଓଡ଼ିଆରେ ଉତ୍ତର ଦିଅନ୍ତୁ (Respond in Odia)",
228
+ "ml": "മലയാളത്തിൽ മറുപടി നൽകുക (Respond in Malayalam)"
229
+ }
230
+
231
+ prompt = f"""
232
+ {lang_instructions.get(language, "Respond in English")}
233
+
234
+ Provide detailed information about the agricultural {pest_disease['type']} known as {pest_disease['name']}
235
+ in the context of Indian agriculture, especially affecting {pest_disease['crop']}.
236
+
237
+ Include the following sections:
238
+ 1. Description and identification
239
+ 2. Lifecycle and spread
240
+ 3. Damage symptoms
241
+ 4. Economic impact
242
+ 5. Management and control measures (both organic and chemical)
243
+ 6. Preventive measures
244
+
245
+ Format the response as JSON with these keys: "description", "lifecycle", "symptoms",
246
+ "impact", "management", "prevention".
247
+
248
+ Each key should contain an object with "title" and "text" fields.
249
+
250
+ For example:
251
+ {{
252
+ "description": {{"title": "Description", "text": "Detailed description..."}},
253
+ "lifecycle": {{"title": "Lifecycle", "text": "Information about lifecycle..."}},
254
+ ...
255
+ }}
256
+
257
+ Ensure the response is strictly valid JSON.
258
+ """
259
+
260
+ try:
261
+ # Call AI API with retry logic
262
+ model = 'gemini-1.5-flash'
263
+ max_retries = 3
264
+ retry_delay = 2
265
+
266
+ for attempt in range(max_retries):
267
+ try:
268
+ response = client.models.generate_content(model=model, contents=prompt)
269
+ break
270
+ except Exception as e:
271
+ if attempt < max_retries - 1:
272
+ logger.warning(f"API call attempt {attempt+1} failed. Retrying...")
273
+ time.sleep(retry_delay)
274
+ retry_delay *= 2
275
+ else:
276
+ logger.error(f"All API call attempts failed for pest_id {pest_id}")
277
+ raise e
278
+
279
+ # Parse the response
280
+ detailed_info = extract_json_from_response(response.text)
281
+
282
+ # Validate the response structure
283
+ required_keys = ["description", "lifecycle", "symptoms", "impact", "management", "prevention"]
284
+ for key in required_keys:
285
+ if key not in detailed_info:
286
+ detailed_info[key] = {"title": f"{key.capitalize()}", "text": "Information not available."}
287
+ elif not isinstance(detailed_info[key], dict):
288
+ detailed_info[key] = {"title": f"{key.capitalize()}", "text": str(detailed_info[key])}
289
+ elif "text" not in detailed_info[key]:
290
+ detailed_info[key]["text"] = "Information not available."
291
+
292
+ # Add timestamp and language info
293
+ result = {
294
+ **pest_disease,
295
+ "details": detailed_info,
296
+ "language": language,
297
+ "cached_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S")
298
+ }
299
+
300
+ # Cache the result
301
+ cache.set(cache_key, result)
302
+ logger.info(f"Successfully cached data for pest_id {pest_id} in {language}")
303
+
304
+ return jsonify(result)
305
+
306
+ except Exception as e:
307
+ logger.error(f"Error: {str(e)}")
308
+ return jsonify({
309
+ "error": "Failed to fetch information",
310
+ "message": "Please try again later."
311
+ }), 500
312
+
313
+ @app.route('/api/pests')
314
+ def get_pests_list():
315
+ return jsonify(PESTS_DISEASES)
316
+
317
+ @app.route('/api/clear-cache/<int:pest_id>', methods=['POST'])
318
+ def clear_specific_cache(pest_id):
319
+ for lang in LANGUAGES.keys():
320
+ cache_key = f"pest_disease_{pest_id}_{lang}"
321
+ cache.delete(cache_key)
322
+ return jsonify({"success": True, "message": f"Cache cleared for pest ID {pest_id}"})
323
+
324
+ if __name__ == '__main__':
325
+ # Use PORT environment variable if available (for Hugging Face Spaces)
326
+ port = int(os.environ.get("PORT", 7860))
327
+ app.run(host="0.0.0.0", port=port)
requirements.txt ADDED
@@ -0,0 +1,5 @@
 
 
 
 
 
 
1
+ Flask==2.0.1
2
+ google-generativeai==0.3.1
3
+ python-dotenv==0.19.0
4
+ cachelib==0.1.1
5
+ gunicorn==20.1.0
space.yml ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ title: Agricultural Pests & Diseases in India
2
+ emoji: 🌱
3
+ colorFrom: green
4
+ colorTo: blue
5
+ sdk: docker
6
+ pinned: false
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,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
+ })
321
+ .then(response => response.json())
322
+ .then(data => {
323
+ if (data.success) {
324
+ // Clear loaded details cache
325
+ loadedDetails = {};
326
+ // Refresh the page
327
+ window.location.reload();
328
+ }
329
+ })
330
+ .catch(error => {
331
+ console.error('Error:', error);
332
+ document.body.removeChild(loadingElement);
333
+ alert('Error changing language. Please try again.');
334
+ });
335
+ });
336
+
337
+ // Lazy load images
338
+ document.addEventListener("DOMContentLoaded", function() {
339
+ const lazyImages = document.querySelectorAll(".lazy-load");
340
+
341
+ if ("IntersectionObserver" in window) {
342
+ const imageObserver = new IntersectionObserver(function(entries, observer) {
343
+ entries.forEach(function(entry) {
344
+ if (entry.isIntersecting) {
345
+ const img = entry.target;
346
+ img.src = img.dataset.src;
347
+ img.classList.remove("lazy-load");
348
+ imageObserver.unobserve(img);
349
+ }
350
+ });
351
+ });
352
+
353
+ lazyImages.forEach(function(image) {
354
+ imageObserver.observe(image);
355
+ });
356
+ } else {
357
+ // Fallback for browsers without intersection observer
358
+ lazyImages.forEach(function(img) {
359
+ img.src = img.dataset.src;
360
+ });
361
+ }
362
+ });
363
+
364
+ // Filter functionality
365
+ document.querySelectorAll('.filter-btn').forEach(button => {
366
+ button.addEventListener('click', function() {
367
+ document.querySelectorAll('.filter-btn').forEach(btn => {
368
+ btn.classList.remove('active');
369
+ });
370
+
371
+ this.classList.add('active');
372
+
373
+ const filterValue = this.getAttribute('data-filter');
374
+ filterItems(filterValue, document.getElementById('searchInput').value);
375
+ });
376
+ });
377
+
378
+ // Search functionality
379
+ document.getElementById('searchInput').addEventListener('input', function() {
380
+ const activeFilter = document.querySelector('.filter-btn.active').getAttribute('data-filter');
381
+ filterItems(activeFilter, this.value);
382
+ });
383
+
384
+ function filterItems(typeFilter, searchTerm) {
385
+ searchTerm = searchTerm.toLowerCase();
386
+ let visibleCount = 0;
387
+
388
+ document.querySelectorAll('.item-card').forEach(item => {
389
+ const type = item.getAttribute('data-type');
390
+ const name = item.getAttribute('data-name');
391
+ const crop = item.getAttribute('data-crop');
392
+
393
+ const typeMatch = typeFilter === 'all' || type === typeFilter;
394
+ const searchMatch = !searchTerm ||
395
+ name.includes(searchTerm) ||
396
+ crop.includes(searchTerm);
397
+
398
+ if (typeMatch && searchMatch) {
399
+ item.style.display = 'block';
400
+ visibleCount++;
401
+ } else {
402
+ item.style.display = 'none';
403
+ }
404
+ });
405
+
406
+ // Show/hide no results message
407
+ document.getElementById('noResults').style.display = visibleCount === 0 ? 'block' : 'none';
408
+ }
409
+
410
+ // Function to show details
411
+ function showDetails(id) {
412
+ // Clear previous content and show loading
413
+ document.getElementById('modalTitle').textContent = "";
414
+ document.getElementById('modalBody').innerHTML = `
415
+ <div class="text-center p-5" id="modalLoading">
416
+ <div class="spinner"></div>
417
+ <p class="mt-3">Loading content...</p>
418
+ </div>
419
+ `;
420
+
421
+ // Show modal immediately with loading indicator
422
+ detailModal.show();
423
+
424
+ // Check if we already have the details cached
425
+ if (loadedDetails[id]) {
426
+ displayDetails(loadedDetails[id]);
427
+ return;
428
+ }
429
+
430
+ // Fetch details from the backend
431
+ fetch(`/get_details/${id}`)
432
+ .then(response => {
433
+ if (!response.ok) {
434
+ throw new Error('Network response was not ok');
435
+ }
436
+ return response.json();
437
+ })
438
+ .then(data => {
439
+ // Cache the result
440
+ loadedDetails[id] = data;
441
+ displayDetails(data);
442
+ })
443
+ .catch(error => {
444
+ console.error('Error:', error);
445
+ document.getElementById('modalBody').innerHTML = `
446
+ <div class="alert alert-danger" role="alert">
447
+ <i class="fas fa-exclamation-circle"></i>
448
+ Error loading information. Please try again.
449
+ </div>
450
+ `;
451
+ });
452
+ }
453
+
454
+ function displayDetails(data) {
455
+ // Update modal title
456
+ document.getElementById('modalTitle').textContent = data.name;
457
+
458
+ // Format the details
459
+ const details = data.details;
460
+ let content = `
461
+ <div class="text-center mb-4">
462
+ <img src="${data.image_url}" alt="${data.name}" class="img-fluid rounded" style="max-height: 300px;">
463
+ <div class="mt-2">
464
+ <span class="badge ${data.type} mb-2">${data.type.charAt(0).toUpperCase() + data.type.slice(1)}</span>
465
+ <div>
466
+ <strong>Affects:</strong>
467
+ ${data.crop.split(', ').map(crop => `<span class="badge crop-badge">${crop}</span>`).join(' ')}
468
+ </div>
469
+ </div>
470
+ </div>
471
+ `;
472
+
473
+ // Add each section
474
+ const sections = [
475
+ {key: 'description', icon: 'info-circle', title: details.description.title || 'Description and Identification'},
476
+ {key: 'lifecycle', icon: 'sync', title: details.lifecycle.title || 'Lifecycle and Spread'},
477
+ {key: 'symptoms', icon: 'exclamation-triangle', title: details.symptoms.title || 'Damage Symptoms'},
478
+ {key: 'impact', icon: 'chart-line', title: details.impact.title || 'Economic Impact'},
479
+ {key: 'management', icon: 'tasks', title: details.management.title || 'Management and Control'},
480
+ {key: 'prevention', icon: 'shield-alt', title: details.prevention.title || 'Prevention'}
481
+ ];
482
+
483
+ sections.forEach(section => {
484
+ if (details[section.key] && details[section.key].text) {
485
+ content += `
486
+ <h4 class="section-title"><i class="fas fa-${section.icon} me-2"></i>${section.title}</h4>
487
+ <p>${details[section.key].text}</p>
488
+ `;
489
+ }
490
+ });
491
+
492
+ document.getElementById('modalBody').innerHTML = content;
493
+ }
494
+
495
+ // Back to top button functionality
496
+ window.onscroll = function() {
497
+ if (document.body.scrollTop > 20 || document.documentElement.scrollTop > 20) {
498
+ document.getElementById("backToTop").style.display = "block";
499
+ } else {
500
+ document.getElementById("backToTop").style.display = "none";
501
+ }
502
+ };
503
+
504
+ document.getElementById("backToTop").addEventListener("click", function() {
505
+ window.scrollTo({top: 0, behavior: 'smooth'});
506
+ });
507
+ </script>
508
+ </body>
509
+ </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
+ }