Spaces:
Running
Major refactoring: Modular architecture implementation (v5.0.0)
Browse files- Refactored monolithic 1,244-line JavaScript into 6 specialized modules
- Implemented professional modular architecture with clean separation of concerns
- Created modules: AuthManager, ApiClient, UIManager, FormManager, AutoCompleteManager, MediaManager
- Enhanced performance with debounced search, Promise.all(), and optimized DOM queries
- Removed all emojis for professional field research environment
- Added comprehensive error handling and validation
- Maintained backward compatibility - no breaking changes
- Updated documentation (README.md, llm.txt) to reflect new architecture
- Created backup of original app.js file
Benefits:
β Better maintainability and testability
β Improved performance and error handling
β Professional emoji-free interface
β Scalable modular design
β Enterprise-grade code organization
- DOCUMENTATION.md +430 -0
- README.md +16 -13
- SPECTACULAR_VISUALIZATION_ROADMAP.md +353 -0
- llm.txt +68 -10
- static/app.js +30 -22
- static/app.js.backup +1243 -0
- static/index.html +376 -46
- static/js/modules/api-client.js +179 -0
- static/js/modules/auth-manager.js +109 -0
- static/js/modules/autocomplete-manager.js +345 -0
- static/js/modules/form-manager.js +301 -0
- static/js/modules/media-manager.js +298 -0
- static/js/modules/ui-manager.js +204 -0
- static/js/tree-track-app.js +247 -0
@@ -0,0 +1,430 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# TreeTrack Documentation
|
2 |
+
|
3 |
+
## Overview
|
4 |
+
|
5 |
+
TreeTrack is a modern web application for documenting and managing tree data with geospatial mapping capabilities. Built with FastAPI backend and vanilla JavaScript frontend, it features user authentication, file uploads, interactive maps, and persistent cloud storage via Supabase.
|
6 |
+
|
7 |
+
## Architecture
|
8 |
+
|
9 |
+
### Technology Stack
|
10 |
+
- **Backend**: FastAPI (Python)
|
11 |
+
- **Frontend**: Vanilla JavaScript, HTML5, CSS3
|
12 |
+
- **Database**: Supabase PostgreSQL
|
13 |
+
- **Storage**: Supabase Storage (private buckets)
|
14 |
+
- **Authentication**: JWT tokens with cookie support
|
15 |
+
- **Deployment**: Hugging Face Spaces (Docker)
|
16 |
+
|
17 |
+
### Key Features
|
18 |
+
- Tree data entry with photo/audio uploads
|
19 |
+
- Interactive map with tree markers
|
20 |
+
- User authentication and permissions
|
21 |
+
- Auto-complete for tree species
|
22 |
+
- Persistent cloud storage
|
23 |
+
- Mobile-responsive design
|
24 |
+
- PWA capabilities with service worker
|
25 |
+
|
26 |
+
## API Endpoints
|
27 |
+
|
28 |
+
### Authentication APIs
|
29 |
+
|
30 |
+
#### POST `/api/auth/login`
|
31 |
+
Login with username and password
|
32 |
+
```json
|
33 |
+
Request:
|
34 |
+
{
|
35 |
+
"username": "aalekh",
|
36 |
+
"password": "aalekh@maptrees"
|
37 |
+
}
|
38 |
+
|
39 |
+
Response:
|
40 |
+
{
|
41 |
+
"access_token": "jwt_token_here",
|
42 |
+
"token_type": "bearer",
|
43 |
+
"user": {
|
44 |
+
"username": "aalekh",
|
45 |
+
"role": "admin"
|
46 |
+
}
|
47 |
+
}
|
48 |
+
```
|
49 |
+
|
50 |
+
#### GET `/api/auth/validate`
|
51 |
+
Validate current authentication token
|
52 |
+
```json
|
53 |
+
Response:
|
54 |
+
{
|
55 |
+
"username": "aalekh",
|
56 |
+
"role": "admin"
|
57 |
+
}
|
58 |
+
```
|
59 |
+
|
60 |
+
### Tree Management APIs
|
61 |
+
|
62 |
+
#### GET `/api/trees`
|
63 |
+
Get all trees with optional filtering
|
64 |
+
```json
|
65 |
+
Query Parameters:
|
66 |
+
- user: filter by username
|
67 |
+
- limit: number of results (default: 100)
|
68 |
+
|
69 |
+
Response:
|
70 |
+
[
|
71 |
+
{
|
72 |
+
"id": 1,
|
73 |
+
"local_name": "Assamese Name",
|
74 |
+
"scientific_name": "Species scientificus",
|
75 |
+
"common_name": "Common Name",
|
76 |
+
"tree_code": "TC001",
|
77 |
+
"location": "POINT(91.7362 26.1724)",
|
78 |
+
"created_by": "aalekh",
|
79 |
+
"created_at": "2024-01-01T12:00:00Z",
|
80 |
+
"photos": {
|
81 |
+
"trunk_url": "https://signed-url...",
|
82 |
+
"leaves_url": "https://signed-url...",
|
83 |
+
"flowers_url": "https://signed-url...",
|
84 |
+
"fruits_url": "https://signed-url..."
|
85 |
+
},
|
86 |
+
"audio_url": "https://signed-url..."
|
87 |
+
}
|
88 |
+
]
|
89 |
+
```
|
90 |
+
|
91 |
+
#### POST `/api/trees`
|
92 |
+
Create a new tree record
|
93 |
+
```json
|
94 |
+
Request (multipart/form-data):
|
95 |
+
- local_name: string
|
96 |
+
- scientific_name: string
|
97 |
+
- common_name: string
|
98 |
+
- tree_code: string
|
99 |
+
- latitude: float
|
100 |
+
- longitude: float
|
101 |
+
- height: float
|
102 |
+
- girth: float
|
103 |
+
- fruiting_season: string
|
104 |
+
- notes: string
|
105 |
+
- trunk_photo: file (optional)
|
106 |
+
- leaves_photo: file (optional)
|
107 |
+
- flowers_photo: file (optional)
|
108 |
+
- fruits_photo: file (optional)
|
109 |
+
- audio: file (optional)
|
110 |
+
|
111 |
+
Response:
|
112 |
+
{
|
113 |
+
"id": 1,
|
114 |
+
"message": "Tree created successfully"
|
115 |
+
}
|
116 |
+
```
|
117 |
+
|
118 |
+
#### PUT `/api/trees/{tree_id}`
|
119 |
+
Update an existing tree record
|
120 |
+
```json
|
121 |
+
Request: Same as POST /api/trees
|
122 |
+
Response: Same as POST /api/trees
|
123 |
+
```
|
124 |
+
|
125 |
+
#### DELETE `/api/trees/{tree_id}`
|
126 |
+
Delete a tree record
|
127 |
+
```json
|
128 |
+
Response:
|
129 |
+
{
|
130 |
+
"message": "Tree deleted successfully"
|
131 |
+
}
|
132 |
+
```
|
133 |
+
|
134 |
+
### File Management APIs
|
135 |
+
|
136 |
+
#### GET `/api/trees/{tree_id}/photo/{photo_type}`
|
137 |
+
Get signed URL for tree photo
|
138 |
+
- photo_type: trunk, leaves, flowers, fruits
|
139 |
+
|
140 |
+
#### GET `/api/trees/{tree_id}/audio`
|
141 |
+
Get signed URL for tree audio recording
|
142 |
+
|
143 |
+
### Utility APIs
|
144 |
+
|
145 |
+
#### GET `/api/tree-suggestions`
|
146 |
+
Get autocomplete suggestions for tree species
|
147 |
+
```json
|
148 |
+
Query Parameters:
|
149 |
+
- query: search term
|
150 |
+
- limit: number of results (default: 10)
|
151 |
+
|
152 |
+
Response:
|
153 |
+
[
|
154 |
+
{
|
155 |
+
"local_name": "Assamese Name",
|
156 |
+
"scientific_name": "Species scientificus",
|
157 |
+
"common_name": "Common Name",
|
158 |
+
"tree_code": "TC001",
|
159 |
+
"fruiting_season": "Winter"
|
160 |
+
}
|
161 |
+
]
|
162 |
+
```
|
163 |
+
|
164 |
+
#### GET `/api/statistics`
|
165 |
+
Get application statistics
|
166 |
+
```json
|
167 |
+
Response:
|
168 |
+
{
|
169 |
+
"total_trees": 42,
|
170 |
+
"total_users": 4,
|
171 |
+
"trees_with_photos": 38,
|
172 |
+
"trees_with_audio": 15
|
173 |
+
}
|
174 |
+
```
|
175 |
+
|
176 |
+
#### GET `/api/version`
|
177 |
+
Get current application version
|
178 |
+
```json
|
179 |
+
Response:
|
180 |
+
{
|
181 |
+
"version": "1.0.0",
|
182 |
+
"timestamp": "2024-01-01T12:00:00Z"
|
183 |
+
}
|
184 |
+
```
|
185 |
+
|
186 |
+
## User Authentication
|
187 |
+
|
188 |
+
### User Accounts
|
189 |
+
The system includes predefined user accounts:
|
190 |
+
|
191 |
+
- **aalekh** (Admin): Full permissions
|
192 |
+
- **admin** (System Admin): Full permissions
|
193 |
+
- **ishita** (Researcher): Create/edit own records
|
194 |
+
- **jeeb** (Researcher): Create/edit own records
|
195 |
+
|
196 |
+
### Security
|
197 |
+
- Passwords stored as salted hashes using PBKDF2
|
198 |
+
- JWT tokens for API authentication
|
199 |
+
- HTTP-only cookies for web interface
|
200 |
+
- Environment variables for password configuration
|
201 |
+
- Role-based access control
|
202 |
+
|
203 |
+
### Required Environment Variables
|
204 |
+
```bash
|
205 |
+
AALEKH_PASSWORD=aalekh@maptrees
|
206 |
+
ADMIN_PASSWORD=admin@maptrees
|
207 |
+
ISHITA_PASSWORD=ishita@maptrees
|
208 |
+
JEEB_PASSWORD=jeeb@maptrees
|
209 |
+
SUPABASE_URL=your_supabase_url
|
210 |
+
SUPABASE_KEY=your_supabase_key
|
211 |
+
SUPABASE_SERVICE_KEY=your_service_key
|
212 |
+
```
|
213 |
+
|
214 |
+
## Database Schema
|
215 |
+
|
216 |
+
### Trees Table
|
217 |
+
```sql
|
218 |
+
CREATE TABLE trees (
|
219 |
+
id SERIAL PRIMARY KEY,
|
220 |
+
local_name VARCHAR(255),
|
221 |
+
scientific_name VARCHAR(255),
|
222 |
+
common_name VARCHAR(255),
|
223 |
+
tree_code VARCHAR(50),
|
224 |
+
location GEOMETRY(POINT, 4326),
|
225 |
+
latitude DECIMAL(10, 8),
|
226 |
+
longitude DECIMAL(11, 8),
|
227 |
+
height DECIMAL(5, 2),
|
228 |
+
girth DECIMAL(5, 2),
|
229 |
+
fruiting_season VARCHAR(100),
|
230 |
+
notes TEXT,
|
231 |
+
trunk_photo VARCHAR(255),
|
232 |
+
leaves_photo VARCHAR(255),
|
233 |
+
flowers_photo VARCHAR(255),
|
234 |
+
fruits_photo VARCHAR(255),
|
235 |
+
audio_file VARCHAR(255),
|
236 |
+
created_by VARCHAR(100),
|
237 |
+
created_at TIMESTAMP DEFAULT NOW(),
|
238 |
+
updated_at TIMESTAMP DEFAULT NOW()
|
239 |
+
);
|
240 |
+
```
|
241 |
+
|
242 |
+
## File Storage
|
243 |
+
|
244 |
+
### Supabase Storage Structure
|
245 |
+
```
|
246 |
+
Buckets:
|
247 |
+
βββ tree-photos/
|
248 |
+
β βββ {tree_id}/
|
249 |
+
β β βββ trunk.jpg
|
250 |
+
β β βββ leaves.jpg
|
251 |
+
β β βββ flowers.jpg
|
252 |
+
β β βββ fruits.jpg
|
253 |
+
βββ tree-audio/
|
254 |
+
βββ {tree_id}/
|
255 |
+
βββ recording.webm
|
256 |
+
```
|
257 |
+
|
258 |
+
### File Access
|
259 |
+
- All files stored in private buckets
|
260 |
+
- Access via signed URLs (24-hour expiry)
|
261 |
+
- Automatic cleanup of expired URLs
|
262 |
+
- Support for images (JPEG, PNG, WebP) and audio (WebM, MP3, WAV)
|
263 |
+
|
264 |
+
## Frontend Components
|
265 |
+
|
266 |
+
### Main Form (`/`)
|
267 |
+
- Tree data entry form
|
268 |
+
- Photo capture/upload (4 categories)
|
269 |
+
- Audio recording
|
270 |
+
- Location selection via map
|
271 |
+
- Auto-complete for species
|
272 |
+
- Mobile-responsive design
|
273 |
+
|
274 |
+
### Map View (`/map`)
|
275 |
+
- Interactive Leaflet map
|
276 |
+
- Tree markers with popups
|
277 |
+
- Filter by user
|
278 |
+
- Edit/delete functionality
|
279 |
+
- Location-based clustering
|
280 |
+
|
281 |
+
### Login Page (`/login`)
|
282 |
+
- User authentication
|
283 |
+
- Quick-select user accounts
|
284 |
+
- Password security
|
285 |
+
- Session management
|
286 |
+
|
287 |
+
## Mobile Support
|
288 |
+
|
289 |
+
### Progressive Web App (PWA)
|
290 |
+
- Service worker for caching
|
291 |
+
- Offline capability
|
292 |
+
- App-like experience
|
293 |
+
- Push notification support
|
294 |
+
|
295 |
+
### Responsive Design
|
296 |
+
- Mobile-first approach
|
297 |
+
- Touch-friendly controls
|
298 |
+
- Optimized for various screen sizes
|
299 |
+
- Camera integration for photos
|
300 |
+
|
301 |
+
## Deployment
|
302 |
+
|
303 |
+
### Hugging Face Spaces
|
304 |
+
The application is deployed on Hugging Face Spaces using Docker.
|
305 |
+
|
306 |
+
#### Configuration Files
|
307 |
+
- `Dockerfile`: Container setup
|
308 |
+
- `requirements.txt`: Python dependencies
|
309 |
+
- `.env`: Environment variables (not in repo)
|
310 |
+
- `app.py`: Main application entry point
|
311 |
+
|
312 |
+
#### Deployment Process
|
313 |
+
1. Push changes to HF Spaces repository
|
314 |
+
2. Docker container rebuilds automatically
|
315 |
+
3. Environment variables configured in Spaces secrets
|
316 |
+
4. Application starts on container startup
|
317 |
+
|
318 |
+
### Local Development
|
319 |
+
```bash
|
320 |
+
# Clone repository
|
321 |
+
git clone https://huggingface.co/spaces/your-username/TreeTrack
|
322 |
+
cd TreeTrack
|
323 |
+
|
324 |
+
# Install dependencies
|
325 |
+
pip install -r requirements.txt
|
326 |
+
|
327 |
+
# Set environment variables
|
328 |
+
cp .env.example .env
|
329 |
+
# Edit .env with your credentials
|
330 |
+
|
331 |
+
# Run application
|
332 |
+
uvicorn app:app --reload --host 0.0.0.0 --port 7860
|
333 |
+
```
|
334 |
+
|
335 |
+
## Cache Management
|
336 |
+
|
337 |
+
### Service Worker Strategy
|
338 |
+
- Network-first for API calls
|
339 |
+
- Cache-first for static assets
|
340 |
+
- Version-based cache invalidation
|
341 |
+
- Automatic cache updates
|
342 |
+
|
343 |
+
### Version Control
|
344 |
+
- Timestamp-based versioning
|
345 |
+
- Automatic cache busting
|
346 |
+
- Development environment detection
|
347 |
+
- Manual cache refresh endpoints
|
348 |
+
|
349 |
+
## Security Considerations
|
350 |
+
|
351 |
+
### Data Protection
|
352 |
+
- HTTPS enforced
|
353 |
+
- Private file storage
|
354 |
+
- Signed URLs with expiry
|
355 |
+
- User-based access control
|
356 |
+
|
357 |
+
### Authentication Security
|
358 |
+
- Password hashing with salt
|
359 |
+
- JWT token expiration
|
360 |
+
- HTTP-only cookies
|
361 |
+
- CSRF protection
|
362 |
+
|
363 |
+
## Troubleshooting
|
364 |
+
|
365 |
+
### Common Issues
|
366 |
+
|
367 |
+
#### Cache Problems
|
368 |
+
```bash
|
369 |
+
# Clear browser cache
|
370 |
+
# Unregister service workers
|
371 |
+
# Check version endpoints
|
372 |
+
GET /api/version
|
373 |
+
```
|
374 |
+
|
375 |
+
#### Authentication Issues
|
376 |
+
```bash
|
377 |
+
# Verify environment variables
|
378 |
+
# Check user credentials
|
379 |
+
# Clear cookies and localStorage
|
380 |
+
```
|
381 |
+
|
382 |
+
#### File Upload Problems
|
383 |
+
```bash
|
384 |
+
# Check Supabase storage configuration
|
385 |
+
# Verify bucket permissions
|
386 |
+
# Monitor file size limits
|
387 |
+
```
|
388 |
+
|
389 |
+
### Debug Endpoints
|
390 |
+
- `/api/version`: Check app version
|
391 |
+
- `/api/statistics`: Verify data counts
|
392 |
+
- Browser DevTools: Network and Console tabs
|
393 |
+
|
394 |
+
## Contributing
|
395 |
+
|
396 |
+
### Development Workflow
|
397 |
+
1. Fork the repository
|
398 |
+
2. Create feature branch
|
399 |
+
3. Make changes with tests
|
400 |
+
4. Submit pull request
|
401 |
+
5. Code review and merge
|
402 |
+
|
403 |
+
### Code Standards
|
404 |
+
- Python: PEP 8 compliance
|
405 |
+
- JavaScript: ESLint configuration
|
406 |
+
- Documentation: Markdown format
|
407 |
+
- Commits: Conventional commit messages
|
408 |
+
|
409 |
+
## Resources
|
410 |
+
|
411 |
+
### External Links
|
412 |
+
- [FastAPI Documentation](https://fastapi.tiangolo.com/)
|
413 |
+
- [Supabase Documentation](https://supabase.com/docs)
|
414 |
+
- [Leaflet Maps](https://leafletjs.com/)
|
415 |
+
- [Hugging Face Spaces](https://huggingface.co/spaces)
|
416 |
+
|
417 |
+
### Internal Files
|
418 |
+
- `llm.txt`: LLM knowledge base
|
419 |
+
- `SECURITY.md`: Security documentation
|
420 |
+
- `requirements.txt`: Dependencies
|
421 |
+
- `static/`: Frontend assets
|
422 |
+
|
423 |
+
---
|
424 |
+
|
425 |
+
## Support
|
426 |
+
|
427 |
+
For issues, questions, or contributions, please contact the development team or create an issue in the repository.
|
428 |
+
|
429 |
+
**Last Updated**: January 2024
|
430 |
+
**Version**: 1.0.0
|
@@ -1,29 +1,30 @@
|
|
1 |
---
|
2 |
title: TreeTrack
|
3 |
-
emoji: π³
|
4 |
colorFrom: green
|
5 |
colorTo: blue
|
6 |
sdk: docker
|
7 |
app_port: 7860
|
8 |
pinned: false
|
9 |
license: mit
|
10 |
-
short_description:
|
11 |
---
|
12 |
|
13 |
-
#
|
14 |
|
15 |
-
A **secure, robust, and high-performance** web application for mapping, tracking, and managing urban forest data using FastAPI with comprehensive security implementations and
|
16 |
|
17 |
-
##
|
18 |
|
|
|
19 |
- **146 Pre-loaded Tree Species**: Comprehensive database from Tezpur research team
|
20 |
-
- **Smart Auto-completion**: Real-time species suggestions
|
21 |
- **Multi-language Support**: Local Assamese names, scientific names, common names
|
22 |
- **Tree Code Validation**: Reference codes (AA, AP, AC, etc.) with instant lookup
|
23 |
- **Auto-fill Fields**: Selecting one suggestion populates related fields automatically
|
24 |
-
- **
|
|
|
25 |
|
26 |
-
##
|
27 |
|
28 |
- **Interactive Tree Mapping**: Add, edit, and visualize trees on an interactive map
|
29 |
- **Intelligent Species Identification**: Auto-suggestions from master species database
|
@@ -33,7 +34,7 @@ A **secure, robust, and high-performance** web application for mapping, tracking
|
|
33 |
- **RESTful API**: Full API documentation with FastAPI's automatic OpenAPI docs
|
34 |
- **Responsive Design**: Works on desktop and mobile devices
|
35 |
|
36 |
-
##
|
37 |
|
38 |
1. **View Trees**: Browse the interactive map to see all registered trees
|
39 |
2. **Add New Trees**: Click on the map to add new tree entries with detailed information
|
@@ -41,7 +42,7 @@ A **secure, robust, and high-performance** web application for mapping, tracking
|
|
41 |
4. **API Access**: Use the `/docs` endpoint for full API documentation
|
42 |
5. **Statistics**: View comprehensive statistics about your urban forest
|
43 |
|
44 |
-
##
|
45 |
|
46 |
### Core Tree Management
|
47 |
- `GET /` - Main application interface
|
@@ -64,7 +65,7 @@ A **secure, robust, and high-performance** web application for mapping, tracking
|
|
64 |
- `GET /download/csv` - Download CSV export of tree data
|
65 |
- `GET /download/status` - Download database status report
|
66 |
|
67 |
-
##
|
68 |
|
69 |
- Input validation and sanitization
|
70 |
- SQL injection prevention
|
@@ -73,14 +74,16 @@ A **secure, robust, and high-performance** web application for mapping, tracking
|
|
73 |
- Secure file path validation
|
74 |
- Comprehensive error handling
|
75 |
|
76 |
-
##
|
77 |
|
78 |
- **Backend**: FastAPI (Python 3.11+)
|
79 |
- **Database**: SQLite with WAL mode
|
80 |
-
- **Frontend**:
|
|
|
81 |
- **Mapping**: Interactive maps with marker clustering
|
82 |
- **Validation**: Pydantic models with custom validators
|
83 |
- **Security**: Multiple layers of protection
|
|
|
84 |
|
85 |
---
|
86 |
|
|
|
1 |
---
|
2 |
title: TreeTrack
|
|
|
3 |
colorFrom: green
|
4 |
colorTo: blue
|
5 |
sdk: docker
|
6 |
app_port: 7860
|
7 |
pinned: false
|
8 |
license: mit
|
9 |
+
short_description: Professional field research platform for tree mapping and urban forestry management
|
10 |
---
|
11 |
|
12 |
+
# TreeTrack - Professional Field Research Platform
|
13 |
|
14 |
+
A **secure, robust, and high-performance** web application for mapping, tracking, and managing urban forest data using FastAPI with comprehensive security implementations and modern modular architecture.
|
15 |
|
16 |
+
## NEW: Modular Architecture & Enhanced Features
|
17 |
|
18 |
+
- **Modular JavaScript Architecture**: Clean separation of concerns with 6 specialized modules
|
19 |
- **146 Pre-loaded Tree Species**: Comprehensive database from Tezpur research team
|
20 |
+
- **Smart Auto-completion**: Real-time species suggestions with debounced search
|
21 |
- **Multi-language Support**: Local Assamese names, scientific names, common names
|
22 |
- **Tree Code Validation**: Reference codes (AA, AP, AC, etc.) with instant lookup
|
23 |
- **Auto-fill Fields**: Selecting one suggestion populates related fields automatically
|
24 |
+
- **Enhanced Performance**: Optimized API calls and improved error handling
|
25 |
+
- **Professional UI**: Emoji-free interface suitable for research environments
|
26 |
|
27 |
+
## Core Features
|
28 |
|
29 |
- **Interactive Tree Mapping**: Add, edit, and visualize trees on an interactive map
|
30 |
- **Intelligent Species Identification**: Auto-suggestions from master species database
|
|
|
34 |
- **RESTful API**: Full API documentation with FastAPI's automatic OpenAPI docs
|
35 |
- **Responsive Design**: Works on desktop and mobile devices
|
36 |
|
37 |
+
## Usage
|
38 |
|
39 |
1. **View Trees**: Browse the interactive map to see all registered trees
|
40 |
2. **Add New Trees**: Click on the map to add new tree entries with detailed information
|
|
|
42 |
4. **API Access**: Use the `/docs` endpoint for full API documentation
|
43 |
5. **Statistics**: View comprehensive statistics about your urban forest
|
44 |
|
45 |
+
## API Endpoints
|
46 |
|
47 |
### Core Tree Management
|
48 |
- `GET /` - Main application interface
|
|
|
65 |
- `GET /download/csv` - Download CSV export of tree data
|
66 |
- `GET /download/status` - Download database status report
|
67 |
|
68 |
+
## Security Features
|
69 |
|
70 |
- Input validation and sanitization
|
71 |
- SQL injection prevention
|
|
|
74 |
- Secure file path validation
|
75 |
- Comprehensive error handling
|
76 |
|
77 |
+
## Technical Stack
|
78 |
|
79 |
- **Backend**: FastAPI (Python 3.11+)
|
80 |
- **Database**: SQLite with WAL mode
|
81 |
+
- **Frontend**: Modular JavaScript with ES6 modules
|
82 |
+
- **Architecture**: 6 specialized modules (Auth, API, UI, Form, AutoComplete, Media)
|
83 |
- **Mapping**: Interactive maps with marker clustering
|
84 |
- **Validation**: Pydantic models with custom validators
|
85 |
- **Security**: Multiple layers of protection
|
86 |
+
- **Performance**: Debounced search, Promise.all(), optimized DOM queries
|
87 |
|
88 |
---
|
89 |
|
@@ -0,0 +1,353 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
# TreeTrack Spectacular Visualization Roadmap
|
2 |
+
|
3 |
+
## Vision Statement
|
4 |
+
Transform TreeTrack into a spectacular, immersive tree exploration experience with interactive storytelling and thematic navigation, while maintaining data collection as the primary objective.
|
5 |
+
|
6 |
+
---
|
7 |
+
|
8 |
+
## Core Philosophy
|
9 |
+
**"Data Collection First, Spectacular Visualization Second"**
|
10 |
+
|
11 |
+
- Preserve and enhance existing robust data collection system
|
12 |
+
- Add spectacular visualization as progressive enhancement
|
13 |
+
- Maintain mobile-first approach for field researchers
|
14 |
+
- Ensure backward compatibility and performance
|
15 |
+
|
16 |
+
---
|
17 |
+
|
18 |
+
## Current State Assessment
|
19 |
+
|
20 |
+
### What We Have (Strong Foundation)
|
21 |
+
- **Comprehensive Data Model**: 12 detailed tree fields including geolocation, measurements, phenology, utilities
|
22 |
+
- **Rich Media Support**: Photo categories (Leaf, Bark, Fruit, Seed, Flower, Full tree), audio storytelling
|
23 |
+
- **Professional Map Interface**: Leaflet-based with modern design system
|
24 |
+
- **Authentication System**: Multi-user with role-based permissions
|
25 |
+
- **Cloud Infrastructure**: Supabase backend with file storage
|
26 |
+
- **Master Species Database**: 146 tree species with autocomplete
|
27 |
+
- **Mobile PWA**: Service worker, offline capability, responsive design
|
28 |
+
|
29 |
+
### What Needs Fixing First
|
30 |
+
1. **Cache Management Issues**: Service worker causing development problems
|
31 |
+
2. **Map Performance**: Optimize marker clustering and rendering
|
32 |
+
3. **Mobile UX**: Enhance touch interactions and form usability
|
33 |
+
4. **Audio Integration**: Improve recording and playback experience
|
34 |
+
5. **Photo Management**: Better categorization and thumbnail generation
|
35 |
+
6. **Search & Filter**: More intuitive tree discovery
|
36 |
+
|
37 |
+
---
|
38 |
+
|
39 |
+
## Implementation Phases
|
40 |
+
|
41 |
+
### **Phase 0: Foundation Fixes** (Priority 1 - Immediate)
|
42 |
+
*Focus: Fix current issues and strengthen the foundation*
|
43 |
+
|
44 |
+
#### Week 1-2: Critical Fixes
|
45 |
+
- [ ] **Cache Management Overhaul**
|
46 |
+
- Fix service worker caching issues
|
47 |
+
- Implement proper versioning strategy
|
48 |
+
- Add development mode detection
|
49 |
+
|
50 |
+
- [ ] **Mobile Experience Enhancement**
|
51 |
+
- Optimize form layout for mobile data entry
|
52 |
+
- Improve camera integration
|
53 |
+
- Better audio recording interface
|
54 |
+
- Touch-friendly map interactions
|
55 |
+
|
56 |
+
- [ ] **Performance Optimization**
|
57 |
+
- Implement efficient marker clustering
|
58 |
+
- Add progressive loading for large datasets
|
59 |
+
- Optimize image loading and thumbnails
|
60 |
+
|
61 |
+
#### Week 3-4: Core Improvements
|
62 |
+
- [ ] **Enhanced Search & Discovery**
|
63 |
+
- Advanced filtering by species, utilities, phenology
|
64 |
+
- Improved autocomplete with fuzzy matching
|
65 |
+
- Location-based tree discovery
|
66 |
+
|
67 |
+
- [ ] **Data Quality Tools**
|
68 |
+
- Validation improvements
|
69 |
+
- Data completeness indicators
|
70 |
+
- Bulk edit capabilities for researchers
|
71 |
+
|
72 |
+
---
|
73 |
+
|
74 |
+
### **Phase 1: Spectacular Story Mode** (Priority 2 - Q2 2025)
|
75 |
+
*Focus: Add immersive exploration mode with dark theme and interactive storytelling*
|
76 |
+
|
77 |
+
#### Core Features
|
78 |
+
- [ ] **Story Mode Toggle**
|
79 |
+
- Switch between "Data Collection Mode" and "Story Mode"
|
80 |
+
- Dark, immersive theme with geographic aesthetics
|
81 |
+
- Smooth transitions and animations
|
82 |
+
|
83 |
+
- [ ] **Interactive Tree Stories**
|
84 |
+
- Click trees to reveal rich story popups
|
85 |
+
- Integration with existing storytelling text and audio
|
86 |
+
- Photo galleries with smooth lightbox experience
|
87 |
+
- Cultural and ecological significance highlighting
|
88 |
+
|
89 |
+
- [ ] **Thematic Navigation** ("Travel by Themes")
|
90 |
+
- **Religious Trees**: Filter trees with religious significance
|
91 |
+
- **Medicine & Healing**: Traditional medicinal uses
|
92 |
+
- **Food & Sustenance**: Edible fruits, leaves, bark
|
93 |
+
- **Biodiversity Hotspots**: Ecological importance
|
94 |
+
- **Cultural Heritage**: Local traditions and stories
|
95 |
+
- **Seasonal Journeys**: Phenology-based exploration
|
96 |
+
|
97 |
+
#### Visual Enhancements
|
98 |
+
- [ ] **Immersive Dark Theme**
|
99 |
+
- Deep background gradients with atmospheric effects
|
100 |
+
- Subtle geographic pattern overlays
|
101 |
+
- Dynamic lighting based on tree themes
|
102 |
+
|
103 |
+
- [ ] **Animated Tree Markers**
|
104 |
+
- Pulsing markers that respond to themes
|
105 |
+
- Color-coded by utilities or phenology stages
|
106 |
+
- Smooth zoom and transition animations
|
107 |
+
|
108 |
+
- [ ] **Atmospheric UI Elements**
|
109 |
+
- Overlay text: "CLICK ON TREE TO DISCOVER ITS STORY"
|
110 |
+
- Elegant typography with multiple language support
|
111 |
+
- Floating navigation elements
|
112 |
+
|
113 |
+
#### Technical Implementation
|
114 |
+
- [ ] **Enhanced Leaflet Integration**
|
115 |
+
- Custom marker animations with CSS transforms
|
116 |
+
- Advanced popup system with rich media
|
117 |
+
- Smooth map transitions and effects
|
118 |
+
|
119 |
+
- [ ] **Audio Storytelling System**
|
120 |
+
- Background audio playback
|
121 |
+
- Spatial audio effects (trees closer = louder)
|
122 |
+
- Audio waveform visualization
|
123 |
+
|
124 |
+
---
|
125 |
+
|
126 |
+
### **Phase 2: Advanced Exploration Features** (Priority 3 - Q3 2025)
|
127 |
+
*Focus: Next-level interactive experiences*
|
128 |
+
|
129 |
+
#### Advanced Storytelling
|
130 |
+
- [ ] **Multi-Language Support**
|
131 |
+
- Assamese, Hindi, English story versions
|
132 |
+
- Voice narration in local languages
|
133 |
+
- Cultural context for each region
|
134 |
+
|
135 |
+
- [ ] **Temporal Exploration**
|
136 |
+
- "Time Travel" through seasons
|
137 |
+
- Historical tree data visualization
|
138 |
+
- Growth progression animations
|
139 |
+
|
140 |
+
- [ ] **Community Stories**
|
141 |
+
- User-contributed stories and folklore
|
142 |
+
- Community voting on favorite trees
|
143 |
+
- Collaborative storytelling features
|
144 |
+
|
145 |
+
#### Enhanced Visualization
|
146 |
+
- [ ] **3D Tree Visualization**
|
147 |
+
- Three.js integration for 3D tree models
|
148 |
+
- Realistic tree rendering based on measurements
|
149 |
+
- Seasonal appearance changes
|
150 |
+
|
151 |
+
- [ ] **Augmented Reality (AR)**
|
152 |
+
- Mobile AR for tree identification
|
153 |
+
- Overlay information in real-world view
|
154 |
+
- Virtual tree placement and measurement
|
155 |
+
|
156 |
+
#### Data Analytics Dashboard
|
157 |
+
- [ ] **Ecological Insights**
|
158 |
+
- Biodiversity heat maps
|
159 |
+
- Phenology pattern analysis
|
160 |
+
- Climate impact visualization
|
161 |
+
|
162 |
+
- [ ] **Research Tools**
|
163 |
+
- Export capabilities for researchers
|
164 |
+
- Statistical analysis integration
|
165 |
+
- Collaborative research features
|
166 |
+
|
167 |
+
---
|
168 |
+
|
169 |
+
### **Phase 3: Next-Generation Features** (Priority 4 - Q4 2025)
|
170 |
+
*Focus: Cutting-edge technology integration*
|
171 |
+
|
172 |
+
#### AI & Machine Learning
|
173 |
+
- [ ] **Automated Tree Identification**
|
174 |
+
- AI-powered species identification from photos
|
175 |
+
- Automated measurement estimation
|
176 |
+
- Quality validation of data entries
|
177 |
+
|
178 |
+
- [ ] **Predictive Analytics**
|
179 |
+
- Tree health prediction models
|
180 |
+
- Optimal planting location suggestions
|
181 |
+
- Climate change impact forecasting
|
182 |
+
|
183 |
+
#### Advanced Interactions
|
184 |
+
- [ ] **Voice Interface**
|
185 |
+
- Voice-controlled data entry
|
186 |
+
- Audio-guided tree exploration
|
187 |
+
- Hands-free operation for field work
|
188 |
+
|
189 |
+
- [ ] **IoT Integration**
|
190 |
+
- Sensor data integration (soil, weather)
|
191 |
+
- Real-time environmental monitoring
|
192 |
+
- Automated data collection
|
193 |
+
|
194 |
+
#### Community Platform
|
195 |
+
- [ ] **Social Features**
|
196 |
+
- Tree adoption programs
|
197 |
+
- Community challenges and gamification
|
198 |
+
- Educational content and quizzes
|
199 |
+
|
200 |
+
---
|
201 |
+
|
202 |
+
## Technical Architecture Plan
|
203 |
+
|
204 |
+
### Frontend Enhancements
|
205 |
+
```
|
206 |
+
TreeTrack/static/
|
207 |
+
βββ core/
|
208 |
+
β βββ app.js # Core application logic (existing)
|
209 |
+
β βββ map.js # Enhanced map functionality
|
210 |
+
β βββ data-entry.js # Optimized forms
|
211 |
+
βββ story-mode/
|
212 |
+
β βββ story-controller.js # Story mode orchestration
|
213 |
+
β βββ theme-navigation.js # Thematic filtering
|
214 |
+
β βββ audio-player.js # Enhanced audio system
|
215 |
+
β βββ story-popup.js # Rich story popups
|
216 |
+
βββ themes/
|
217 |
+
β βββ default.css # Current professional theme
|
218 |
+
β βββ story-dark.css # Dark immersive theme
|
219 |
+
β βββ mobile.css # Mobile optimizations
|
220 |
+
βββ assets/
|
221 |
+
βββ audio/ # Story narrations
|
222 |
+
βββ textures/ # Geographic patterns
|
223 |
+
βββ icons/ # Themed markers
|
224 |
+
```
|
225 |
+
|
226 |
+
### Backend API Extensions
|
227 |
+
```python
|
228 |
+
# New API endpoints for spectacular features
|
229 |
+
/api/stories/ # Rich story content
|
230 |
+
/api/themes/ # Thematic filtering
|
231 |
+
/api/audio/streaming/ # Audio streaming
|
232 |
+
/api/analytics/ # Usage analytics
|
233 |
+
/api/community/ # Community features
|
234 |
+
```
|
235 |
+
|
236 |
+
### Database Schema Extensions
|
237 |
+
```sql
|
238 |
+
-- Story mode enhancements
|
239 |
+
ALTER TABLE trees ADD COLUMN story_priority INTEGER DEFAULT 0;
|
240 |
+
ALTER TABLE trees ADD COLUMN cultural_significance TEXT;
|
241 |
+
ALTER TABLE trees ADD COLUMN audio_duration DECIMAL(5,2);
|
242 |
+
ALTER TABLE trees ADD COLUMN theme_tags JSON;
|
243 |
+
|
244 |
+
-- Community features
|
245 |
+
CREATE TABLE tree_stories (
|
246 |
+
id SERIAL PRIMARY KEY,
|
247 |
+
tree_id INTEGER REFERENCES trees(id),
|
248 |
+
contributor_name VARCHAR(255),
|
249 |
+
story_text TEXT,
|
250 |
+
audio_file VARCHAR(255),
|
251 |
+
language VARCHAR(10),
|
252 |
+
created_at TIMESTAMP DEFAULT NOW()
|
253 |
+
);
|
254 |
+
```
|
255 |
+
|
256 |
+
---
|
257 |
+
|
258 |
+
## Success Metrics
|
259 |
+
|
260 |
+
### Data Collection (Primary)
|
261 |
+
- **Efficiency**: 50% reduction in data entry time
|
262 |
+
- **Accuracy**: 95% data validation success rate
|
263 |
+
- **Adoption**: 100% of field teams using mobile app
|
264 |
+
- **Coverage**: 1000+ trees documented per quarter
|
265 |
+
|
266 |
+
### Spectacular Visualization (Secondary)
|
267 |
+
- **Engagement**: 80% of users try Story Mode
|
268 |
+
- **Discovery**: Average 5+ trees explored per session
|
269 |
+
- **Community**: 50+ community-contributed stories
|
270 |
+
- **Education**: 90% of users learn something new about trees
|
271 |
+
|
272 |
+
### Technical Performance
|
273 |
+
- **Speed**: <2s load time on mobile
|
274 |
+
- **Reliability**: 99.9% uptime
|
275 |
+
- **Accessibility**: WCAG 2.1 AA compliance
|
276 |
+
- **Cross-platform**: iOS, Android, Desktop compatibility
|
277 |
+
|
278 |
+
---
|
279 |
+
|
280 |
+
## Community Impact Goals
|
281 |
+
|
282 |
+
### Research Excellence
|
283 |
+
- Support 10+ research publications using TreeTrack data
|
284 |
+
- Enable collaboration between 5+ research institutions
|
285 |
+
- Provide open data for conservation initiatives
|
286 |
+
|
287 |
+
### Educational Outreach
|
288 |
+
- Create educational content for schools
|
289 |
+
- Support environmental awareness campaigns
|
290 |
+
- Enable citizen science participation
|
291 |
+
|
292 |
+
### Cultural Preservation
|
293 |
+
- Document traditional ecological knowledge
|
294 |
+
- Preserve local tree-related folklore
|
295 |
+
- Support indigenous community narratives
|
296 |
+
|
297 |
+
---
|
298 |
+
|
299 |
+
## Implementation Priority Matrix
|
300 |
+
|
301 |
+
### **Immediate (Next 2 Months)**
|
302 |
+
1. Fix cache management issues
|
303 |
+
2. Optimize mobile data entry experience
|
304 |
+
3. Improve search and filtering
|
305 |
+
4. Enhance photo management
|
306 |
+
|
307 |
+
### **Short Term (3-6 Months)**
|
308 |
+
1. Implement Story Mode toggle
|
309 |
+
2. Create thematic navigation
|
310 |
+
3. Build interactive story popups
|
311 |
+
4. Add dark immersive theme
|
312 |
+
|
313 |
+
### **Medium Term (6-12 Months)**
|
314 |
+
1. Advanced audio storytelling
|
315 |
+
2. 3D visualization capabilities
|
316 |
+
3. Multi-language support
|
317 |
+
4. Community features
|
318 |
+
|
319 |
+
### **Long Term (12+ Months)**
|
320 |
+
1. AR integration
|
321 |
+
2. AI-powered features
|
322 |
+
3. IoT sensor integration
|
323 |
+
4. Advanced analytics platform
|
324 |
+
|
325 |
+
---
|
326 |
+
|
327 |
+
## Innovation Opportunities
|
328 |
+
|
329 |
+
### Unique Differentiators
|
330 |
+
- **Cultural Integration**: Deep integration of local folklore and traditional knowledge
|
331 |
+
- **Multi-Sensory**: Audio, visual, and tactile experiences
|
332 |
+
- **Field-First Design**: Built specifically for field researchers
|
333 |
+
- **Community-Driven**: Stories and knowledge from local communities
|
334 |
+
|
335 |
+
### Potential Partnerships
|
336 |
+
- **Research Institutions**: University collaborations
|
337 |
+
- **Conservation Organizations**: NGO partnerships
|
338 |
+
- **Technology Companies**: AR/VR technology integration
|
339 |
+
- **Cultural Organizations**: Local folklore documentation
|
340 |
+
|
341 |
+
---
|
342 |
+
|
343 |
+
## Vision Realization
|
344 |
+
|
345 |
+
**The Ultimate Goal**: Transform TreeTrack into the world's most engaging and comprehensive tree documentation platform - where rigorous scientific data collection meets spectacular storytelling, creating an immersive experience that educates, inspires, and preserves the cultural and ecological heritage of trees.
|
346 |
+
|
347 |
+
**Impact Statement**: "Every tree has a story. TreeTrack makes those stories discoverable, preservable, and unforgettable."
|
348 |
+
|
349 |
+
---
|
350 |
+
|
351 |
+
*This roadmap is a living document that evolves with user feedback, technological advances, and community needs. The foundation is strong - now we build something spectacular on top of it.*
|
352 |
+
|
353 |
+
**Next Step**: Focus on Phase 0 critical fixes to ensure the foundation is solid before adding spectacular features.
|
@@ -1,13 +1,15 @@
|
|
1 |
-
# TreeTrack
|
2 |
|
3 |
## PROJECT OVERVIEW
|
4 |
-
TreeTrack is a comprehensive tree mapping and urban forestry management web application deployed on **Hugging Face Spaces** using **Docker**.
|
5 |
|
6 |
### Key Information:
|
7 |
- **Platform**: Hugging Face Spaces (NOT local server)
|
8 |
- **Deployment**: Docker container with port 7860
|
9 |
-
- **Technology Stack**: FastAPI +
|
10 |
-
- **Purpose**:
|
|
|
|
|
11 |
|
12 |
## DEPLOYMENT ARCHITECTURE
|
13 |
|
@@ -28,6 +30,50 @@ EXPOSE 7860
|
|
28 |
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
29 |
```
|
30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
31 |
## TECHNOLOGY STACK
|
32 |
|
33 |
### Backend
|
@@ -38,9 +84,11 @@ CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
|
38 |
- **Validation**: Pydantic 2.10.0+ with custom validators
|
39 |
- **Configuration**: pydantic-settings for environment-based config
|
40 |
|
41 |
-
### Frontend
|
42 |
-
- **JavaScript**:
|
43 |
-
- **
|
|
|
|
|
44 |
- **PWA**: Service Worker for offline capabilities
|
45 |
- **Maps**: Interactive mapping with marker clustering
|
46 |
- **Media**: Camera integration and audio recording
|
@@ -163,13 +211,14 @@ async def add_cache_headers(request: Request, call_next):
|
|
163 |
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
164 |
```
|
165 |
|
166 |
-
## FILE STRUCTURE
|
167 |
|
168 |
```
|
169 |
TreeTrack/
|
170 |
README.md # HF Spaces config + documentation
|
171 |
Dockerfile # HF Spaces Docker configuration
|
172 |
requirements.txt # Python dependencies
|
|
|
173 |
app.py # Main FastAPI application
|
174 |
config.py # Comprehensive configuration system
|
175 |
version.json # Version tracking for cache busting
|
@@ -179,11 +228,20 @@ TreeTrack/
|
|
179 |
.dockerignore-cachebust # Docker ignore with cache busting
|
180 |
.gitattributes # Git LFS configuration
|
181 |
static/
|
182 |
-
index.html # Main application interface
|
183 |
map.html # Interactive map interface
|
184 |
-
app.js
|
185 |
map.js # Map functionality
|
186 |
sw.js # Service Worker for PWA/offline
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
187 |
```
|
188 |
|
189 |
## API ENDPOINTS
|
|
|
1 |
+
# TreeTrack - Professional Field Research Platform - LLM Knowledge Base
|
2 |
|
3 |
## PROJECT OVERVIEW
|
4 |
+
TreeTrack is a comprehensive tree mapping and urban forestry management web application with a modern modular architecture, deployed on **Hugging Face Spaces** using **Docker**.
|
5 |
|
6 |
### Key Information:
|
7 |
- **Platform**: Hugging Face Spaces (NOT local server)
|
8 |
- **Deployment**: Docker container with port 7860
|
9 |
+
- **Technology Stack**: FastAPI + Modular JavaScript (ES6) + SQLite
|
10 |
+
- **Purpose**: Professional field research tool for tree documentation with 12 comprehensive data fields
|
11 |
+
- **Architecture**: Refactored modular design with 6 specialized JavaScript modules
|
12 |
+
- **Version**: 5.0.0 (Major refactoring completed)
|
13 |
|
14 |
## DEPLOYMENT ARCHITECTURE
|
15 |
|
|
|
30 |
CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
|
31 |
```
|
32 |
|
33 |
+
## MAJOR REFACTORING (Version 5.0.0)
|
34 |
+
|
35 |
+
### Transformation Overview
|
36 |
+
- **From**: Monolithic 1,244-line JavaScript file
|
37 |
+
- **To**: Modular architecture with 6 specialized modules
|
38 |
+
- **Benefits**: Better maintainability, testability, and performance
|
39 |
+
- **No Breaking Changes**: All functionality preserved
|
40 |
+
- **Professional UI**: Completely emoji-free interface
|
41 |
+
|
42 |
+
### Modular Architecture
|
43 |
+
|
44 |
+
#### Module Structure
|
45 |
+
```
|
46 |
+
static/js/
|
47 |
+
βββ modules/
|
48 |
+
β βββ auth-manager.js - Authentication & permissions (95 lines)
|
49 |
+
β βββ api-client.js - API communication (148 lines)
|
50 |
+
β βββ ui-manager.js - User interface & messaging (192 lines)
|
51 |
+
β βββ form-manager.js - Form handling & validation (285 lines)
|
52 |
+
β βββ autocomplete-manager.js - Smart suggestions (322 lines)
|
53 |
+
β βββ media-manager.js - File uploads & recording (268 lines)
|
54 |
+
βββ tree-track-app.js - Main orchestrator (206 lines)
|
55 |
+
```
|
56 |
+
|
57 |
+
#### Module Dependencies
|
58 |
+
```
|
59 |
+
TreeTrackApp
|
60 |
+
βββ AuthManager (independent)
|
61 |
+
βββ ApiClient (depends on AuthManager)
|
62 |
+
βββ UIManager (depends on AuthManager)
|
63 |
+
βββ FormManager (depends on ApiClient, UIManager)
|
64 |
+
βββ AutoCompleteManager (depends on ApiClient, UIManager)
|
65 |
+
βββ MediaManager (depends on FormManager, UIManager)
|
66 |
+
```
|
67 |
+
|
68 |
+
#### Key Improvements
|
69 |
+
- **Separation of Concerns**: Each module has single responsibility
|
70 |
+
- **Dependency Injection**: Clean dependencies between modules
|
71 |
+
- **ES6 Modules**: Modern import/export structure
|
72 |
+
- **Event Delegation**: Replaced global functions with proper event handling
|
73 |
+
- **Performance**: Debounced search, Promise.all(), DOM caching
|
74 |
+
- **Error Handling**: Centralized error management
|
75 |
+
- **Code Reuse**: Shared utilities across modules
|
76 |
+
|
77 |
## TECHNOLOGY STACK
|
78 |
|
79 |
### Backend
|
|
|
84 |
- **Validation**: Pydantic 2.10.0+ with custom validators
|
85 |
- **Configuration**: pydantic-settings for environment-based config
|
86 |
|
87 |
+
### Frontend (Refactored)
|
88 |
+
- **JavaScript**: Modular ES6+ with 6 specialized modules
|
89 |
+
- **Architecture**: Dependency injection pattern
|
90 |
+
- **Performance**: Debounced search (300ms), parallel API calls
|
91 |
+
- **CSS**: Custom responsive design with Inter font (emoji-free)
|
92 |
- **PWA**: Service Worker for offline capabilities
|
93 |
- **Maps**: Interactive mapping with marker clustering
|
94 |
- **Media**: Camera integration and audio recording
|
|
|
211 |
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
|
212 |
```
|
213 |
|
214 |
+
## FILE STRUCTURE (Updated)
|
215 |
|
216 |
```
|
217 |
TreeTrack/
|
218 |
README.md # HF Spaces config + documentation
|
219 |
Dockerfile # HF Spaces Docker configuration
|
220 |
requirements.txt # Python dependencies
|
221 |
+
llm.txt # LLM knowledge base (updated)
|
222 |
app.py # Main FastAPI application
|
223 |
config.py # Comprehensive configuration system
|
224 |
version.json # Version tracking for cache busting
|
|
|
228 |
.dockerignore-cachebust # Docker ignore with cache busting
|
229 |
.gitattributes # Git LFS configuration
|
230 |
static/
|
231 |
+
index.html # Main application interface (updated)
|
232 |
map.html # Interactive map interface
|
233 |
+
app.js.backup # Original monolithic file (backup)
|
234 |
map.js # Map functionality
|
235 |
sw.js # Service Worker for PWA/offline
|
236 |
+
js/
|
237 |
+
modules/
|
238 |
+
auth-manager.js # Authentication module
|
239 |
+
api-client.js # API communication module
|
240 |
+
ui-manager.js # UI management module
|
241 |
+
form-manager.js # Form handling module
|
242 |
+
autocomplete-manager.js # Smart suggestions module
|
243 |
+
media-manager.js # Media handling module
|
244 |
+
tree-track-app.js # Main orchestrator
|
245 |
```
|
246 |
|
247 |
## API ENDPOINTS
|
@@ -294,14 +294,22 @@ class TreeTrackApp {
|
|
294 |
const categoryDiv = document.createElement('div');
|
295 |
categoryDiv.className = 'photo-category';
|
296 |
categoryDiv.innerHTML = `
|
297 |
-
<div>
|
298 |
-
<
|
299 |
-
|
300 |
-
|
301 |
</div>
|
302 |
-
<div class="uploaded-file" id="photo-${category}" style="display: none;"></div>
|
303 |
</div>
|
304 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
305 |
`;
|
306 |
container.appendChild(categoryDiv);
|
307 |
});
|
@@ -438,7 +446,7 @@ class TreeTrackApp {
|
|
438 |
// Update UI
|
439 |
const resultDiv = document.getElementById(`photo-${category}`);
|
440 |
resultDiv.style.display = 'block';
|
441 |
-
resultDiv.innerHTML =
|
442 |
} else {
|
443 |
throw new Error('Upload failed');
|
444 |
}
|
@@ -482,7 +490,7 @@ class TreeTrackApp {
|
|
482 |
|
483 |
// Update UI
|
484 |
const resultDiv = document.getElementById('audioUploadResult');
|
485 |
-
resultDiv.innerHTML = `<div class="uploaded-file"
|
486 |
} else {
|
487 |
throw new Error('Upload failed');
|
488 |
}
|
@@ -529,7 +537,7 @@ class TreeTrackApp {
|
|
529 |
const status = document.getElementById('recordingStatus');
|
530 |
if (recordBtn) {
|
531 |
recordBtn.classList.add('recording');
|
532 |
-
recordBtn.innerHTML = '
|
533 |
}
|
534 |
if (status) {
|
535 |
status.textContent = 'Recording... Click to stop';
|
@@ -552,7 +560,7 @@ class TreeTrackApp {
|
|
552 |
const status = document.getElementById('recordingStatus');
|
553 |
if (recordBtn) {
|
554 |
recordBtn.classList.remove('recording');
|
555 |
-
recordBtn.innerHTML = '
|
556 |
}
|
557 |
if (status) {
|
558 |
status.textContent = 'Recording saved!';
|
@@ -562,17 +570,17 @@ class TreeTrackApp {
|
|
562 |
|
563 |
getCurrentLocation() {
|
564 |
if (navigator.geolocation) {
|
565 |
-
document.getElementById('getLocation').textContent = '
|
566 |
|
567 |
navigator.geolocation.getCurrentPosition(
|
568 |
(position) => {
|
569 |
document.getElementById('latitude').value = position.coords.latitude.toFixed(7);
|
570 |
document.getElementById('longitude').value = position.coords.longitude.toFixed(7);
|
571 |
-
document.getElementById('getLocation').textContent = '
|
572 |
this.showMessage('Location retrieved successfully!', 'success');
|
573 |
},
|
574 |
(error) => {
|
575 |
-
document.getElementById('getLocation').textContent = '
|
576 |
this.showMessage('Error getting location: ' + error.message, 'error');
|
577 |
}
|
578 |
);
|
@@ -625,7 +633,7 @@ class TreeTrackApp {
|
|
625 |
|
626 |
if (response.ok) {
|
627 |
const result = await response.json();
|
628 |
-
this.showMessage(
|
629 |
this.resetFormSilently();
|
630 |
this.loadTrees(); // Refresh the tree list
|
631 |
} else {
|
@@ -695,16 +703,16 @@ class TreeTrackApp {
|
|
695 |
<div class="tree-header">
|
696 |
<div class="tree-id">Tree #${tree.id}</div>
|
697 |
<div class="tree-actions">
|
698 |
-
${canEdit ? `<button class="btn-icon edit-tree" onclick="app.editTree(${tree.id})" title="Edit Tree"
|
699 |
-
${canDelete ? `<button class="btn-icon delete-tree" onclick="app.deleteTree(${tree.id})" title="Delete Tree"
|
700 |
</div>
|
701 |
</div>
|
702 |
<div class="tree-info">
|
703 |
${tree.scientific_name || tree.common_name || tree.local_name || 'Unnamed'}
|
704 |
-
<br
|
705 |
-
${tree.tree_code ? `<br
|
706 |
-
<br
|
707 |
-
<br
|
708 |
</div>
|
709 |
</div>
|
710 |
`;
|
@@ -849,7 +857,7 @@ class TreeTrackApp {
|
|
849 |
|
850 |
if (response.ok) {
|
851 |
const result = await response.json();
|
852 |
-
this.showMessage(
|
853 |
this.cancelEdit(); // Exit edit mode
|
854 |
this.loadTrees(); // Refresh the tree list
|
855 |
} else {
|
@@ -875,7 +883,7 @@ class TreeTrackApp {
|
|
875 |
if (!response) return;
|
876 |
|
877 |
if (response.ok) {
|
878 |
-
this.showMessage(
|
879 |
this.loadTrees(); // Refresh the tree list
|
880 |
} else {
|
881 |
const error = await response.json();
|
|
|
294 |
const categoryDiv = document.createElement('div');
|
295 |
categoryDiv.className = 'photo-category';
|
296 |
categoryDiv.innerHTML = `
|
297 |
+
<div class="photo-category-header">
|
298 |
+
<div class="photo-category-title">
|
299 |
+
<div class="photo-category-icon">IMG</div>
|
300 |
+
${category}
|
301 |
</div>
|
|
|
302 |
</div>
|
303 |
+
<div class="photo-upload-area">
|
304 |
+
<div class="photo-upload" data-category="${category}">
|
305 |
+
<div class="photo-upload-icon">+</div>
|
306 |
+
<div>Click to select ${category} photo</div>
|
307 |
+
</div>
|
308 |
+
<button type="button" class="camera-btn" onclick="app.capturePhoto('${category}')">
|
309 |
+
Camera
|
310 |
+
</button>
|
311 |
+
</div>
|
312 |
+
<div class="uploaded-file" id="photo-${category}" style="display: none;"></div>
|
313 |
`;
|
314 |
container.appendChild(categoryDiv);
|
315 |
});
|
|
|
446 |
// Update UI
|
447 |
const resultDiv = document.getElementById(`photo-${category}`);
|
448 |
resultDiv.style.display = 'block';
|
449 |
+
resultDiv.innerHTML = `${file.name} uploaded successfully`;
|
450 |
} else {
|
451 |
throw new Error('Upload failed');
|
452 |
}
|
|
|
490 |
|
491 |
// Update UI
|
492 |
const resultDiv = document.getElementById('audioUploadResult');
|
493 |
+
resultDiv.innerHTML = `<div class="uploaded-file">${file.name} uploaded successfully</div>`;
|
494 |
} else {
|
495 |
throw new Error('Upload failed');
|
496 |
}
|
|
|
537 |
const status = document.getElementById('recordingStatus');
|
538 |
if (recordBtn) {
|
539 |
recordBtn.classList.add('recording');
|
540 |
+
recordBtn.innerHTML = 'Stop';
|
541 |
}
|
542 |
if (status) {
|
543 |
status.textContent = 'Recording... Click to stop';
|
|
|
560 |
const status = document.getElementById('recordingStatus');
|
561 |
if (recordBtn) {
|
562 |
recordBtn.classList.remove('recording');
|
563 |
+
recordBtn.innerHTML = 'Record';
|
564 |
}
|
565 |
if (status) {
|
566 |
status.textContent = 'Recording saved!';
|
|
|
570 |
|
571 |
getCurrentLocation() {
|
572 |
if (navigator.geolocation) {
|
573 |
+
document.getElementById('getLocation').textContent = 'Getting...';
|
574 |
|
575 |
navigator.geolocation.getCurrentPosition(
|
576 |
(position) => {
|
577 |
document.getElementById('latitude').value = position.coords.latitude.toFixed(7);
|
578 |
document.getElementById('longitude').value = position.coords.longitude.toFixed(7);
|
579 |
+
document.getElementById('getLocation').textContent = 'Get GPS Location';
|
580 |
this.showMessage('Location retrieved successfully!', 'success');
|
581 |
},
|
582 |
(error) => {
|
583 |
+
document.getElementById('getLocation').textContent = 'Get GPS Location';
|
584 |
this.showMessage('Error getting location: ' + error.message, 'error');
|
585 |
}
|
586 |
);
|
|
|
633 |
|
634 |
if (response.ok) {
|
635 |
const result = await response.json();
|
636 |
+
this.showMessage(`Tree successfully added! Tree ID: ${result.id}. The form has been cleared for your next entry.`, 'success');
|
637 |
this.resetFormSilently();
|
638 |
this.loadTrees(); // Refresh the tree list
|
639 |
} else {
|
|
|
703 |
<div class="tree-header">
|
704 |
<div class="tree-id">Tree #${tree.id}</div>
|
705 |
<div class="tree-actions">
|
706 |
+
${canEdit ? `<button class="btn-icon edit-tree" onclick="app.editTree(${tree.id})" title="Edit Tree">Edit</button>` : ''}
|
707 |
+
${canDelete ? `<button class="btn-icon delete-tree" onclick="app.deleteTree(${tree.id})" title="Delete Tree">Delete</button>` : ''}
|
708 |
</div>
|
709 |
</div>
|
710 |
<div class="tree-info">
|
711 |
${tree.scientific_name || tree.common_name || tree.local_name || 'Unnamed'}
|
712 |
+
<br>${tree.latitude.toFixed(4)}, ${tree.longitude.toFixed(4)}
|
713 |
+
${tree.tree_code ? `<br>Code: ${tree.tree_code}` : ''}
|
714 |
+
<br>${new Date(tree.created_at).toLocaleDateString()}
|
715 |
+
<br>By: ${tree.created_by || 'Unknown'}
|
716 |
</div>
|
717 |
</div>
|
718 |
`;
|
|
|
857 |
|
858 |
if (response.ok) {
|
859 |
const result = await response.json();
|
860 |
+
this.showMessage(`Tree #${result.id} updated successfully!`, 'success');
|
861 |
this.cancelEdit(); // Exit edit mode
|
862 |
this.loadTrees(); // Refresh the tree list
|
863 |
} else {
|
|
|
883 |
if (!response) return;
|
884 |
|
885 |
if (response.ok) {
|
886 |
+
this.showMessage(`Tree #${treeId} deleted successfully.`, 'success');
|
887 |
this.loadTrees(); // Refresh the tree list
|
888 |
} else {
|
889 |
const error = await response.json();
|
@@ -0,0 +1,1243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
// TreeTrack Enhanced JavaScript - Comprehensive Field Research Tool with Authentication
|
2 |
+
class TreeTrackApp {
|
3 |
+
constructor() {
|
4 |
+
this.uploadedPhotos = {};
|
5 |
+
this.audioFile = null;
|
6 |
+
this.mediaRecorder = null;
|
7 |
+
this.audioChunks = [];
|
8 |
+
this.isRecording = false;
|
9 |
+
|
10 |
+
// Auto-suggestion properties
|
11 |
+
this.searchTimeouts = {};
|
12 |
+
this.activeDropdowns = new Set();
|
13 |
+
this.selectedIndex = -1;
|
14 |
+
this.availableTreeCodes = [];
|
15 |
+
|
16 |
+
// Authentication properties
|
17 |
+
this.currentUser = null;
|
18 |
+
this.authToken = null;
|
19 |
+
|
20 |
+
this.init();
|
21 |
+
}
|
22 |
+
|
23 |
+
async init() {
|
24 |
+
// Check authentication first
|
25 |
+
if (!await this.checkAuthentication()) {
|
26 |
+
window.location.href = '/login';
|
27 |
+
return;
|
28 |
+
}
|
29 |
+
|
30 |
+
await this.loadFormOptions();
|
31 |
+
this.setupEventListeners();
|
32 |
+
this.setupUserInterface();
|
33 |
+
this.loadTrees();
|
34 |
+
this.loadSelectedLocation();
|
35 |
+
|
36 |
+
// Initialize auto-suggestions after a brief delay to ensure DOM is ready
|
37 |
+
setTimeout(() => {
|
38 |
+
this.initializeAutoSuggestions();
|
39 |
+
}, 100);
|
40 |
+
}
|
41 |
+
|
42 |
+
// Authentication methods
|
43 |
+
async checkAuthentication() {
|
44 |
+
const token = localStorage.getItem('auth_token');
|
45 |
+
if (!token) {
|
46 |
+
return false;
|
47 |
+
}
|
48 |
+
|
49 |
+
try {
|
50 |
+
const response = await fetch('/api/auth/validate', {
|
51 |
+
headers: {
|
52 |
+
'Authorization': `Bearer ${token}`
|
53 |
+
}
|
54 |
+
});
|
55 |
+
|
56 |
+
if (response.ok) {
|
57 |
+
const data = await response.json();
|
58 |
+
this.currentUser = data.user;
|
59 |
+
this.authToken = token;
|
60 |
+
return true;
|
61 |
+
} else {
|
62 |
+
// Token invalid, remove it
|
63 |
+
localStorage.removeItem('auth_token');
|
64 |
+
localStorage.removeItem('user_info');
|
65 |
+
return false;
|
66 |
+
}
|
67 |
+
} catch (error) {
|
68 |
+
console.error('Auth validation error:', error);
|
69 |
+
return false;
|
70 |
+
}
|
71 |
+
}
|
72 |
+
|
73 |
+
setupUserInterface() {
|
74 |
+
// Update existing user info elements
|
75 |
+
this.displayUserInfo();
|
76 |
+
|
77 |
+
// Add logout functionality
|
78 |
+
this.addLogoutButton();
|
79 |
+
}
|
80 |
+
|
81 |
+
displayUserInfo() {
|
82 |
+
if (!this.currentUser) return;
|
83 |
+
|
84 |
+
// Update existing user info elements in the new HTML structure
|
85 |
+
const userNameEl = document.getElementById('userName');
|
86 |
+
const userRoleEl = document.getElementById('userRole');
|
87 |
+
const userAvatarEl = document.getElementById('userAvatar');
|
88 |
+
|
89 |
+
if (userNameEl) {
|
90 |
+
userNameEl.textContent = this.currentUser.full_name;
|
91 |
+
}
|
92 |
+
|
93 |
+
if (userRoleEl) {
|
94 |
+
userRoleEl.textContent = this.currentUser.role;
|
95 |
+
}
|
96 |
+
|
97 |
+
if (userAvatarEl) {
|
98 |
+
userAvatarEl.textContent = this.currentUser.full_name.charAt(0).toUpperCase();
|
99 |
+
}
|
100 |
+
}
|
101 |
+
|
102 |
+
addLogoutButton() {
|
103 |
+
const logoutBtn = document.getElementById('logoutBtn');
|
104 |
+
if (logoutBtn) {
|
105 |
+
logoutBtn.addEventListener('click', () => this.logout());
|
106 |
+
}
|
107 |
+
}
|
108 |
+
|
109 |
+
addCustomStyles() {
|
110 |
+
const style = document.createElement('style');
|
111 |
+
style.textContent = `
|
112 |
+
.user-info {
|
113 |
+
color: white;
|
114 |
+
text-align: center;
|
115 |
+
margin: 0 1rem;
|
116 |
+
}
|
117 |
+
.user-greeting {
|
118 |
+
font-size: 0.875rem;
|
119 |
+
font-weight: 500;
|
120 |
+
}
|
121 |
+
.user-role {
|
122 |
+
font-size: 0.75rem;
|
123 |
+
opacity: 0.8;
|
124 |
+
text-transform: capitalize;
|
125 |
+
}
|
126 |
+
.tree-header {
|
127 |
+
display: flex;
|
128 |
+
justify-content: space-between;
|
129 |
+
align-items: center;
|
130 |
+
margin-bottom: 0.5rem;
|
131 |
+
}
|
132 |
+
.tree-actions {
|
133 |
+
display: flex;
|
134 |
+
gap: 0.25rem;
|
135 |
+
}
|
136 |
+
.btn-icon {
|
137 |
+
background: none;
|
138 |
+
border: none;
|
139 |
+
cursor: pointer;
|
140 |
+
padding: 0.25rem;
|
141 |
+
border-radius: 4px;
|
142 |
+
font-size: 0.875rem;
|
143 |
+
transition: background-color 0.2s;
|
144 |
+
}
|
145 |
+
.btn-icon:hover {
|
146 |
+
background-color: rgba(0,0,0,0.1);
|
147 |
+
}
|
148 |
+
.edit-tree:hover {
|
149 |
+
background-color: rgba(59, 130, 246, 0.1);
|
150 |
+
}
|
151 |
+
.delete-tree:hover {
|
152 |
+
background-color: rgba(239, 68, 68, 0.1);
|
153 |
+
}
|
154 |
+
.logout-btn {
|
155 |
+
margin-left: 1rem;
|
156 |
+
}
|
157 |
+
@media (max-width: 768px) {
|
158 |
+
.user-info {
|
159 |
+
margin: 0 0.5rem;
|
160 |
+
}
|
161 |
+
.user-greeting {
|
162 |
+
font-size: 0.75rem;
|
163 |
+
}
|
164 |
+
.user-role {
|
165 |
+
font-size: 0.625rem;
|
166 |
+
}
|
167 |
+
}
|
168 |
+
`;
|
169 |
+
document.head.appendChild(style);
|
170 |
+
}
|
171 |
+
|
172 |
+
async logout() {
|
173 |
+
try {
|
174 |
+
await fetch('/api/auth/logout', {
|
175 |
+
method: 'POST',
|
176 |
+
headers: {
|
177 |
+
'Authorization': `Bearer ${this.authToken}`
|
178 |
+
}
|
179 |
+
});
|
180 |
+
} catch (error) {
|
181 |
+
console.error('Logout error:', error);
|
182 |
+
} finally {
|
183 |
+
localStorage.removeItem('auth_token');
|
184 |
+
localStorage.removeItem('user_info');
|
185 |
+
window.location.href = '/login';
|
186 |
+
}
|
187 |
+
}
|
188 |
+
|
189 |
+
// Enhanced API calls with authentication
|
190 |
+
async authenticatedFetch(url, options = {}) {
|
191 |
+
const headers = {
|
192 |
+
'Content-Type': 'application/json',
|
193 |
+
'Authorization': `Bearer ${this.authToken}`,
|
194 |
+
...options.headers
|
195 |
+
};
|
196 |
+
|
197 |
+
const response = await fetch(url, {
|
198 |
+
...options,
|
199 |
+
headers
|
200 |
+
});
|
201 |
+
|
202 |
+
if (response.status === 401) {
|
203 |
+
// Token expired or invalid
|
204 |
+
localStorage.removeItem('auth_token');
|
205 |
+
localStorage.removeItem('user_info');
|
206 |
+
window.location.href = '/login';
|
207 |
+
return null;
|
208 |
+
}
|
209 |
+
|
210 |
+
return response;
|
211 |
+
}
|
212 |
+
|
213 |
+
// Permission checking methods
|
214 |
+
canEditTree(createdBy) {
|
215 |
+
if (!this.currentUser) return false;
|
216 |
+
|
217 |
+
// Admin and system can edit any tree
|
218 |
+
if (this.currentUser.permissions.includes('admin') || this.currentUser.permissions.includes('system')) {
|
219 |
+
return true;
|
220 |
+
}
|
221 |
+
|
222 |
+
// Users can edit trees they created
|
223 |
+
if (this.currentUser.permissions.includes('edit_own') && createdBy === this.currentUser.username) {
|
224 |
+
return true;
|
225 |
+
}
|
226 |
+
|
227 |
+
// Users with delete permission can edit any tree
|
228 |
+
if (this.currentUser.permissions.includes('delete')) {
|
229 |
+
return true;
|
230 |
+
}
|
231 |
+
|
232 |
+
return false;
|
233 |
+
}
|
234 |
+
|
235 |
+
canDeleteTree(createdBy) {
|
236 |
+
if (!this.currentUser) return false;
|
237 |
+
|
238 |
+
// Only admin and system can delete trees
|
239 |
+
if (this.currentUser.permissions.includes('admin') || this.currentUser.permissions.includes('system')) {
|
240 |
+
return true;
|
241 |
+
}
|
242 |
+
|
243 |
+
// Users with explicit delete permission
|
244 |
+
if (this.currentUser.permissions.includes('delete')) {
|
245 |
+
return true;
|
246 |
+
}
|
247 |
+
|
248 |
+
return false;
|
249 |
+
}
|
250 |
+
|
251 |
+
async loadFormOptions() {
|
252 |
+
try {
|
253 |
+
// Load utility options
|
254 |
+
const utilityResponse = await this.authenticatedFetch('/api/utilities');
|
255 |
+
if (!utilityResponse) return;
|
256 |
+
const utilityData = await utilityResponse.json();
|
257 |
+
this.renderMultiSelect('utilityOptions', utilityData.utilities);
|
258 |
+
|
259 |
+
// Load phenology stages
|
260 |
+
const phenologyResponse = await this.authenticatedFetch('/api/phenology-stages');
|
261 |
+
if (!phenologyResponse) return;
|
262 |
+
const phenologyData = await phenologyResponse.json();
|
263 |
+
this.renderMultiSelect('phenologyOptions', phenologyData.stages);
|
264 |
+
|
265 |
+
// Load photo categories
|
266 |
+
const categoriesResponse = await this.authenticatedFetch('/api/photo-categories');
|
267 |
+
if (!categoriesResponse) return;
|
268 |
+
const categoriesData = await categoriesResponse.json();
|
269 |
+
this.renderPhotoCategories(categoriesData.categories);
|
270 |
+
|
271 |
+
} catch (error) {
|
272 |
+
console.error('Error loading form options:', error);
|
273 |
+
}
|
274 |
+
}
|
275 |
+
|
276 |
+
renderMultiSelect(containerId, options) {
|
277 |
+
const container = document.getElementById(containerId);
|
278 |
+
container.innerHTML = '';
|
279 |
+
|
280 |
+
options.forEach(option => {
|
281 |
+
const label = document.createElement('label');
|
282 |
+
label.innerHTML = `
|
283 |
+
<input type="checkbox" value="${option}"> ${option}
|
284 |
+
`;
|
285 |
+
container.appendChild(label);
|
286 |
+
});
|
287 |
+
}
|
288 |
+
|
289 |
+
renderPhotoCategories(categories) {
|
290 |
+
const container = document.getElementById('photoCategories');
|
291 |
+
container.innerHTML = '';
|
292 |
+
|
293 |
+
categories.forEach(category => {
|
294 |
+
const categoryDiv = document.createElement('div');
|
295 |
+
categoryDiv.className = 'photo-category';
|
296 |
+
categoryDiv.innerHTML = `
|
297 |
+
<div class="photo-category-header">
|
298 |
+
<div class="photo-category-title">
|
299 |
+
<div class="photo-category-icon">IMG</div>
|
300 |
+
${category}
|
301 |
+
</div>
|
302 |
+
</div>
|
303 |
+
<div class="photo-upload-area">
|
304 |
+
<div class="photo-upload" data-category="${category}">
|
305 |
+
<div class="photo-upload-icon">+</div>
|
306 |
+
<div>Click to select ${category} photo</div>
|
307 |
+
</div>
|
308 |
+
<button type="button" class="camera-btn" onclick="app.capturePhoto('${category}')">
|
309 |
+
Camera
|
310 |
+
</button>
|
311 |
+
</div>
|
312 |
+
<div class="uploaded-file" id="photo-${category}" style="display: none;"></div>
|
313 |
+
`;
|
314 |
+
container.appendChild(categoryDiv);
|
315 |
+
});
|
316 |
+
|
317 |
+
this.setupPhotoUploads();
|
318 |
+
}
|
319 |
+
|
320 |
+
setupEventListeners() {
|
321 |
+
// Form submission
|
322 |
+
document.getElementById('treeForm').addEventListener('submit', (e) => this.handleSubmit(e));
|
323 |
+
|
324 |
+
// Reset form
|
325 |
+
document.getElementById('resetForm').addEventListener('click', () => this.resetForm());
|
326 |
+
|
327 |
+
// GPS location
|
328 |
+
document.getElementById('getLocation').addEventListener('click', () => this.getCurrentLocation());
|
329 |
+
|
330 |
+
// Audio recording - check if element exists
|
331 |
+
const recordBtn = document.getElementById('recordBtn');
|
332 |
+
if (recordBtn) {
|
333 |
+
recordBtn.addEventListener('click', () => this.toggleRecording());
|
334 |
+
}
|
335 |
+
|
336 |
+
// Audio file upload
|
337 |
+
document.getElementById('audioUpload').addEventListener('click', () => this.selectAudioFile());
|
338 |
+
|
339 |
+
// Drag and drop for audio
|
340 |
+
this.setupDragAndDrop();
|
341 |
+
}
|
342 |
+
|
343 |
+
loadSelectedLocation() {
|
344 |
+
// Load location from map selection
|
345 |
+
const selectedLocation = localStorage.getItem('selectedLocation');
|
346 |
+
if (selectedLocation) {
|
347 |
+
try {
|
348 |
+
const location = JSON.parse(selectedLocation);
|
349 |
+
document.getElementById('latitude').value = location.lat.toFixed(6);
|
350 |
+
document.getElementById('longitude').value = location.lng.toFixed(6);
|
351 |
+
|
352 |
+
// Clear the stored location
|
353 |
+
localStorage.removeItem('selectedLocation');
|
354 |
+
|
355 |
+
// Show success message
|
356 |
+
this.showMessage('Location loaded from map!', 'success');
|
357 |
+
} catch (error) {
|
358 |
+
console.error('Error loading selected location:', error);
|
359 |
+
}
|
360 |
+
}
|
361 |
+
}
|
362 |
+
|
363 |
+
setupPhotoUploads() {
|
364 |
+
document.querySelectorAll('.photo-upload').forEach(upload => {
|
365 |
+
upload.addEventListener('click', (e) => {
|
366 |
+
const category = e.target.getAttribute('data-category');
|
367 |
+
this.selectPhotoFile(category);
|
368 |
+
});
|
369 |
+
});
|
370 |
+
}
|
371 |
+
|
372 |
+
setupDragAndDrop() {
|
373 |
+
const audioUpload = document.getElementById('audioUpload');
|
374 |
+
|
375 |
+
audioUpload.addEventListener('dragover', (e) => {
|
376 |
+
e.preventDefault();
|
377 |
+
audioUpload.classList.add('dragover');
|
378 |
+
});
|
379 |
+
|
380 |
+
audioUpload.addEventListener('dragleave', () => {
|
381 |
+
audioUpload.classList.remove('dragover');
|
382 |
+
});
|
383 |
+
|
384 |
+
audioUpload.addEventListener('drop', (e) => {
|
385 |
+
e.preventDefault();
|
386 |
+
audioUpload.classList.remove('dragover');
|
387 |
+
|
388 |
+
const files = e.dataTransfer.files;
|
389 |
+
if (files.length > 0 && files[0].type.startsWith('audio/')) {
|
390 |
+
this.uploadAudioFile(files[0]);
|
391 |
+
}
|
392 |
+
});
|
393 |
+
}
|
394 |
+
|
395 |
+
async selectPhotoFile(category) {
|
396 |
+
const input = document.createElement('input');
|
397 |
+
input.type = 'file';
|
398 |
+
input.accept = 'image/*';
|
399 |
+
input.capture = 'environment'; // Use rear camera if available
|
400 |
+
|
401 |
+
input.onchange = (e) => {
|
402 |
+
const file = e.target.files[0];
|
403 |
+
if (file) {
|
404 |
+
this.uploadPhotoFile(file, category);
|
405 |
+
}
|
406 |
+
};
|
407 |
+
|
408 |
+
input.click();
|
409 |
+
}
|
410 |
+
|
411 |
+
async capturePhoto(category) {
|
412 |
+
// For mobile devices, this will trigger the camera
|
413 |
+
const input = document.createElement('input');
|
414 |
+
input.type = 'file';
|
415 |
+
input.accept = 'image/*';
|
416 |
+
input.capture = 'environment';
|
417 |
+
|
418 |
+
input.onchange = (e) => {
|
419 |
+
const file = e.target.files[0];
|
420 |
+
if (file) {
|
421 |
+
this.uploadPhotoFile(file, category);
|
422 |
+
}
|
423 |
+
};
|
424 |
+
|
425 |
+
input.click();
|
426 |
+
}
|
427 |
+
|
428 |
+
async uploadPhotoFile(file, category) {
|
429 |
+
const formData = new FormData();
|
430 |
+
formData.append('file', file);
|
431 |
+
formData.append('category', category);
|
432 |
+
|
433 |
+
try {
|
434 |
+
const response = await fetch('/api/upload/image', {
|
435 |
+
method: 'POST',
|
436 |
+
headers: {
|
437 |
+
'Authorization': `Bearer ${this.authToken}`
|
438 |
+
},
|
439 |
+
body: formData
|
440 |
+
});
|
441 |
+
|
442 |
+
if (response.ok) {
|
443 |
+
const result = await response.json();
|
444 |
+
this.uploadedPhotos[category] = result.filename;
|
445 |
+
|
446 |
+
// Update UI
|
447 |
+
const resultDiv = document.getElementById(`photo-${category}`);
|
448 |
+
resultDiv.style.display = 'block';
|
449 |
+
resultDiv.innerHTML = `${file.name} uploaded successfully`;
|
450 |
+
} else {
|
451 |
+
throw new Error('Upload failed');
|
452 |
+
}
|
453 |
+
} catch (error) {
|
454 |
+
console.error('Error uploading photo:', error);
|
455 |
+
this.showMessage('Error uploading photo: ' + error.message, 'error');
|
456 |
+
}
|
457 |
+
}
|
458 |
+
|
459 |
+
async selectAudioFile() {
|
460 |
+
const input = document.createElement('input');
|
461 |
+
input.type = 'file';
|
462 |
+
input.accept = 'audio/*';
|
463 |
+
|
464 |
+
input.onchange = (e) => {
|
465 |
+
const file = e.target.files[0];
|
466 |
+
if (file) {
|
467 |
+
this.uploadAudioFile(file);
|
468 |
+
}
|
469 |
+
};
|
470 |
+
|
471 |
+
input.click();
|
472 |
+
}
|
473 |
+
|
474 |
+
async uploadAudioFile(file) {
|
475 |
+
const formData = new FormData();
|
476 |
+
formData.append('file', file);
|
477 |
+
|
478 |
+
try {
|
479 |
+
const response = await fetch('/api/upload/audio', {
|
480 |
+
method: 'POST',
|
481 |
+
headers: {
|
482 |
+
'Authorization': `Bearer ${this.authToken}`
|
483 |
+
},
|
484 |
+
body: formData
|
485 |
+
});
|
486 |
+
|
487 |
+
if (response.ok) {
|
488 |
+
const result = await response.json();
|
489 |
+
this.audioFile = result.filename;
|
490 |
+
|
491 |
+
// Update UI
|
492 |
+
const resultDiv = document.getElementById('audioUploadResult');
|
493 |
+
resultDiv.innerHTML = `<div class="uploaded-file">${file.name} uploaded successfully</div>`;
|
494 |
+
} else {
|
495 |
+
throw new Error('Upload failed');
|
496 |
+
}
|
497 |
+
} catch (error) {
|
498 |
+
console.error('Error uploading audio:', error);
|
499 |
+
this.showMessage('Error uploading audio: ' + error.message, 'error');
|
500 |
+
}
|
501 |
+
}
|
502 |
+
|
503 |
+
async toggleRecording() {
|
504 |
+
if (!this.isRecording) {
|
505 |
+
await this.startRecording();
|
506 |
+
} else {
|
507 |
+
this.stopRecording();
|
508 |
+
}
|
509 |
+
}
|
510 |
+
|
511 |
+
async startRecording() {
|
512 |
+
try {
|
513 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
514 |
+
this.mediaRecorder = new MediaRecorder(stream);
|
515 |
+
this.audioChunks = [];
|
516 |
+
|
517 |
+
this.mediaRecorder.ondataavailable = (event) => {
|
518 |
+
this.audioChunks.push(event.data);
|
519 |
+
};
|
520 |
+
|
521 |
+
this.mediaRecorder.onstop = async () => {
|
522 |
+
const audioBlob = new Blob(this.audioChunks, { type: 'audio/wav' });
|
523 |
+
const audioFile = new File([audioBlob], 'recording.wav', { type: 'audio/wav' });
|
524 |
+
await this.uploadAudioFile(audioFile);
|
525 |
+
|
526 |
+
// Show playback
|
527 |
+
const audioElement = document.getElementById('audioPlayback');
|
528 |
+
audioElement.src = URL.createObjectURL(audioBlob);
|
529 |
+
audioElement.classList.remove('hidden');
|
530 |
+
};
|
531 |
+
|
532 |
+
this.mediaRecorder.start();
|
533 |
+
this.isRecording = true;
|
534 |
+
|
535 |
+
// Update UI - check if elements exist
|
536 |
+
const recordBtn = document.getElementById('recordBtn');
|
537 |
+
const status = document.getElementById('recordingStatus');
|
538 |
+
if (recordBtn) {
|
539 |
+
recordBtn.classList.add('recording');
|
540 |
+
recordBtn.innerHTML = 'Stop';
|
541 |
+
}
|
542 |
+
if (status) {
|
543 |
+
status.textContent = 'Recording... Click to stop';
|
544 |
+
}
|
545 |
+
|
546 |
+
} catch (error) {
|
547 |
+
console.error('Error starting recording:', error);
|
548 |
+
this.showMessage('Error accessing microphone: ' + error.message, 'error');
|
549 |
+
}
|
550 |
+
}
|
551 |
+
|
552 |
+
stopRecording() {
|
553 |
+
if (this.mediaRecorder && this.isRecording) {
|
554 |
+
this.mediaRecorder.stop();
|
555 |
+
this.mediaRecorder.stream.getTracks().forEach(track => track.stop());
|
556 |
+
this.isRecording = false;
|
557 |
+
|
558 |
+
// Update UI - check if elements exist
|
559 |
+
const recordBtn = document.getElementById('recordBtn');
|
560 |
+
const status = document.getElementById('recordingStatus');
|
561 |
+
if (recordBtn) {
|
562 |
+
recordBtn.classList.remove('recording');
|
563 |
+
recordBtn.innerHTML = 'Record';
|
564 |
+
}
|
565 |
+
if (status) {
|
566 |
+
status.textContent = 'Recording saved!';
|
567 |
+
}
|
568 |
+
}
|
569 |
+
}
|
570 |
+
|
571 |
+
getCurrentLocation() {
|
572 |
+
if (navigator.geolocation) {
|
573 |
+
document.getElementById('getLocation').textContent = 'Getting...';
|
574 |
+
|
575 |
+
navigator.geolocation.getCurrentPosition(
|
576 |
+
(position) => {
|
577 |
+
document.getElementById('latitude').value = position.coords.latitude.toFixed(7);
|
578 |
+
document.getElementById('longitude').value = position.coords.longitude.toFixed(7);
|
579 |
+
document.getElementById('getLocation').textContent = 'Get GPS Location';
|
580 |
+
this.showMessage('Location retrieved successfully!', 'success');
|
581 |
+
},
|
582 |
+
(error) => {
|
583 |
+
document.getElementById('getLocation').textContent = 'Get GPS Location';
|
584 |
+
this.showMessage('Error getting location: ' + error.message, 'error');
|
585 |
+
}
|
586 |
+
);
|
587 |
+
} else {
|
588 |
+
this.showMessage('Geolocation is not supported by this browser.', 'error');
|
589 |
+
}
|
590 |
+
}
|
591 |
+
|
592 |
+
getSelectedValues(containerId) {
|
593 |
+
const container = document.getElementById(containerId);
|
594 |
+
const checkboxes = container.querySelectorAll('input[type="checkbox"]:checked');
|
595 |
+
return Array.from(checkboxes).map(cb => cb.value);
|
596 |
+
}
|
597 |
+
|
598 |
+
async handleSubmit(e) {
|
599 |
+
e.preventDefault();
|
600 |
+
|
601 |
+
const utilityValues = this.getSelectedValues('utilityOptions');
|
602 |
+
const phenologyValues = this.getSelectedValues('phenologyOptions');
|
603 |
+
|
604 |
+
const treeData = {
|
605 |
+
latitude: parseFloat(document.getElementById('latitude').value),
|
606 |
+
longitude: parseFloat(document.getElementById('longitude').value),
|
607 |
+
local_name: document.getElementById('localName').value || null,
|
608 |
+
scientific_name: document.getElementById('scientificName').value || null,
|
609 |
+
common_name: document.getElementById('commonName').value || null,
|
610 |
+
tree_code: document.getElementById('treeCode').value || null,
|
611 |
+
height: document.getElementById('height').value ? parseFloat(document.getElementById('height').value) : null,
|
612 |
+
width: document.getElementById('width').value ? parseFloat(document.getElementById('width').value) : null,
|
613 |
+
utility: utilityValues.length > 0 ? utilityValues : [],
|
614 |
+
phenology_stages: phenologyValues.length > 0 ? phenologyValues : [],
|
615 |
+
storytelling_text: document.getElementById('storytellingText').value || null,
|
616 |
+
storytelling_audio: this.audioFile,
|
617 |
+
photographs: Object.keys(this.uploadedPhotos).length > 0 ? this.uploadedPhotos : null,
|
618 |
+
notes: document.getElementById('notes').value || null
|
619 |
+
};
|
620 |
+
|
621 |
+
// Debug log to check the data structure
|
622 |
+
console.log('Tree data being sent:', treeData);
|
623 |
+
console.log('Utility type:', typeof treeData.utility, treeData.utility);
|
624 |
+
console.log('Phenology type:', typeof treeData.phenology_stages, treeData.phenology_stages);
|
625 |
+
|
626 |
+
try {
|
627 |
+
const response = await this.authenticatedFetch('/api/trees', {
|
628 |
+
method: 'POST',
|
629 |
+
body: JSON.stringify(treeData)
|
630 |
+
});
|
631 |
+
|
632 |
+
if (!response) return;
|
633 |
+
|
634 |
+
if (response.ok) {
|
635 |
+
const result = await response.json();
|
636 |
+
this.showMessage(`Tree successfully added! Tree ID: ${result.id}. The form has been cleared for your next entry.`, 'success');
|
637 |
+
this.resetFormSilently();
|
638 |
+
this.loadTrees(); // Refresh the tree list
|
639 |
+
} else {
|
640 |
+
const error = await response.json();
|
641 |
+
this.showMessage('Error saving tree: ' + (error.detail || 'Unknown error'), 'error');
|
642 |
+
}
|
643 |
+
} catch (error) {
|
644 |
+
console.error('Error submitting form:', error);
|
645 |
+
this.showMessage('Network error: ' + error.message, 'error');
|
646 |
+
}
|
647 |
+
}
|
648 |
+
|
649 |
+
resetForm() {
|
650 |
+
this.resetFormSilently();
|
651 |
+
this.showMessage('Form has been reset.', 'success');
|
652 |
+
}
|
653 |
+
|
654 |
+
resetFormSilently() {
|
655 |
+
document.getElementById('treeForm').reset();
|
656 |
+
this.uploadedPhotos = {};
|
657 |
+
this.audioFile = null;
|
658 |
+
|
659 |
+
// Clear uploaded file indicators
|
660 |
+
document.querySelectorAll('.uploaded-file').forEach(el => {
|
661 |
+
el.style.display = 'none';
|
662 |
+
el.innerHTML = '';
|
663 |
+
});
|
664 |
+
|
665 |
+
// Reset audio controls - check if elements exist
|
666 |
+
const audioElement = document.getElementById('audioPlayback');
|
667 |
+
if (audioElement) {
|
668 |
+
audioElement.classList.add('hidden');
|
669 |
+
audioElement.src = '';
|
670 |
+
}
|
671 |
+
|
672 |
+
const recordingStatus = document.getElementById('recordingStatus');
|
673 |
+
if (recordingStatus) {
|
674 |
+
recordingStatus.textContent = 'Click to start recording';
|
675 |
+
}
|
676 |
+
|
677 |
+
const audioUploadResult = document.getElementById('audioUploadResult');
|
678 |
+
if (audioUploadResult) {
|
679 |
+
audioUploadResult.innerHTML = '';
|
680 |
+
}
|
681 |
+
}
|
682 |
+
|
683 |
+
async loadTrees() {
|
684 |
+
try {
|
685 |
+
const response = await this.authenticatedFetch('/api/trees?limit=20');
|
686 |
+
if (!response) return;
|
687 |
+
|
688 |
+
const trees = await response.json();
|
689 |
+
|
690 |
+
const treeList = document.getElementById('treeList');
|
691 |
+
|
692 |
+
if (trees.length === 0) {
|
693 |
+
treeList.innerHTML = '<div class="loading">No trees recorded yet</div>';
|
694 |
+
return;
|
695 |
+
}
|
696 |
+
|
697 |
+
treeList.innerHTML = trees.map(tree => {
|
698 |
+
const canEdit = this.canEditTree(tree.created_by);
|
699 |
+
const canDelete = this.canDeleteTree(tree.created_by);
|
700 |
+
|
701 |
+
return `
|
702 |
+
<div class="tree-item" data-tree-id="${tree.id}">
|
703 |
+
<div class="tree-header">
|
704 |
+
<div class="tree-id">Tree #${tree.id}</div>
|
705 |
+
<div class="tree-actions">
|
706 |
+
${canEdit ? `<button class="btn-icon edit-tree" onclick="app.editTree(${tree.id})" title="Edit Tree">Edit</button>` : ''}
|
707 |
+
${canDelete ? `<button class="btn-icon delete-tree" onclick="app.deleteTree(${tree.id})" title="Delete Tree">Delete</button>` : ''}
|
708 |
+
</div>
|
709 |
+
</div>
|
710 |
+
<div class="tree-info">
|
711 |
+
${tree.scientific_name || tree.common_name || tree.local_name || 'Unnamed'}
|
712 |
+
<br>${tree.latitude.toFixed(4)}, ${tree.longitude.toFixed(4)}
|
713 |
+
${tree.tree_code ? `<br>Code: ${tree.tree_code}` : ''}
|
714 |
+
<br>${new Date(tree.created_at).toLocaleDateString()}
|
715 |
+
<br>By: ${tree.created_by || 'Unknown'}
|
716 |
+
</div>
|
717 |
+
</div>
|
718 |
+
`;
|
719 |
+
}).join('');
|
720 |
+
|
721 |
+
} catch (error) {
|
722 |
+
console.error('Error loading trees:', error);
|
723 |
+
document.getElementById('treeList').innerHTML = '<div class="loading">Error loading trees</div>';
|
724 |
+
}
|
725 |
+
}
|
726 |
+
|
727 |
+
async editTree(treeId) {
|
728 |
+
try {
|
729 |
+
const response = await this.authenticatedFetch(`/api/trees/${treeId}`);
|
730 |
+
if (!response) return;
|
731 |
+
|
732 |
+
if (!response.ok) {
|
733 |
+
throw new Error('Failed to fetch tree data');
|
734 |
+
}
|
735 |
+
|
736 |
+
const tree = await response.json();
|
737 |
+
|
738 |
+
// Populate form with tree data
|
739 |
+
document.getElementById('latitude').value = tree.latitude;
|
740 |
+
document.getElementById('longitude').value = tree.longitude;
|
741 |
+
document.getElementById('localName').value = tree.local_name || '';
|
742 |
+
document.getElementById('scientificName').value = tree.scientific_name || '';
|
743 |
+
document.getElementById('commonName').value = tree.common_name || '';
|
744 |
+
document.getElementById('treeCode').value = tree.tree_code || '';
|
745 |
+
document.getElementById('height').value = tree.height || '';
|
746 |
+
document.getElementById('width').value = tree.width || '';
|
747 |
+
document.getElementById('storytellingText').value = tree.storytelling_text || '';
|
748 |
+
document.getElementById('notes').value = tree.notes || '';
|
749 |
+
|
750 |
+
// Handle utility checkboxes
|
751 |
+
if (tree.utility && Array.isArray(tree.utility)) {
|
752 |
+
document.querySelectorAll('#utilityOptions input[type="checkbox"]').forEach(checkbox => {
|
753 |
+
checkbox.checked = tree.utility.includes(checkbox.value);
|
754 |
+
});
|
755 |
+
}
|
756 |
+
|
757 |
+
// Handle phenology checkboxes
|
758 |
+
if (tree.phenology_stages && Array.isArray(tree.phenology_stages)) {
|
759 |
+
document.querySelectorAll('#phenologyOptions input[type="checkbox"]').forEach(checkbox => {
|
760 |
+
checkbox.checked = tree.phenology_stages.includes(checkbox.value);
|
761 |
+
});
|
762 |
+
}
|
763 |
+
|
764 |
+
// Update form to edit mode
|
765 |
+
this.setEditMode(treeId);
|
766 |
+
|
767 |
+
this.showMessage(`Loaded tree #${treeId} for editing. Make changes and save.`, 'success');
|
768 |
+
|
769 |
+
} catch (error) {
|
770 |
+
console.error('Error loading tree for edit:', error);
|
771 |
+
this.showMessage('Error loading tree data: ' + error.message, 'error');
|
772 |
+
}
|
773 |
+
}
|
774 |
+
|
775 |
+
setEditMode(treeId) {
|
776 |
+
// Change form submit behavior
|
777 |
+
const form = document.getElementById('treeForm');
|
778 |
+
form.dataset.editId = treeId;
|
779 |
+
|
780 |
+
// Update submit button
|
781 |
+
const submitBtn = document.querySelector('button[type="submit"]');
|
782 |
+
submitBtn.textContent = 'Update Tree Record';
|
783 |
+
|
784 |
+
// Add cancel edit button
|
785 |
+
if (!document.getElementById('cancelEdit')) {
|
786 |
+
const cancelBtn = document.createElement('button');
|
787 |
+
cancelBtn.type = 'button';
|
788 |
+
cancelBtn.id = 'cancelEdit';
|
789 |
+
cancelBtn.className = 'btn btn-outline';
|
790 |
+
cancelBtn.textContent = 'Cancel Edit';
|
791 |
+
cancelBtn.addEventListener('click', () => this.cancelEdit());
|
792 |
+
|
793 |
+
const formActions = document.querySelector('.form-actions');
|
794 |
+
formActions.insertBefore(cancelBtn, submitBtn);
|
795 |
+
}
|
796 |
+
|
797 |
+
// Update form submit handler for edit mode
|
798 |
+
form.removeEventListener('submit', this.handleSubmit);
|
799 |
+
form.addEventListener('submit', (e) => this.handleEditSubmit(e, treeId));
|
800 |
+
}
|
801 |
+
|
802 |
+
cancelEdit() {
|
803 |
+
// Reset form
|
804 |
+
this.resetFormSilently();
|
805 |
+
|
806 |
+
// Remove edit mode
|
807 |
+
const form = document.getElementById('treeForm');
|
808 |
+
delete form.dataset.editId;
|
809 |
+
|
810 |
+
// Restore original submit button
|
811 |
+
const submitBtn = document.querySelector('button[type="submit"]');
|
812 |
+
submitBtn.textContent = 'Save Tree Record';
|
813 |
+
|
814 |
+
// Remove cancel button
|
815 |
+
const cancelBtn = document.getElementById('cancelEdit');
|
816 |
+
if (cancelBtn) {
|
817 |
+
cancelBtn.remove();
|
818 |
+
}
|
819 |
+
|
820 |
+
// Restore original form handler
|
821 |
+
form.removeEventListener('submit', this.handleEditSubmit);
|
822 |
+
form.addEventListener('submit', (e) => this.handleSubmit(e));
|
823 |
+
|
824 |
+
this.showMessage('Edit cancelled. Form cleared.', 'success');
|
825 |
+
}
|
826 |
+
|
827 |
+
async handleEditSubmit(e, treeId) {
|
828 |
+
e.preventDefault();
|
829 |
+
|
830 |
+
const utilityValues = this.getSelectedValues('utilityOptions');
|
831 |
+
const phenologyValues = this.getSelectedValues('phenologyOptions');
|
832 |
+
|
833 |
+
const treeData = {
|
834 |
+
latitude: parseFloat(document.getElementById('latitude').value),
|
835 |
+
longitude: parseFloat(document.getElementById('longitude').value),
|
836 |
+
local_name: document.getElementById('localName').value || null,
|
837 |
+
scientific_name: document.getElementById('scientificName').value || null,
|
838 |
+
common_name: document.getElementById('commonName').value || null,
|
839 |
+
tree_code: document.getElementById('treeCode').value || null,
|
840 |
+
height: document.getElementById('height').value ? parseFloat(document.getElementById('height').value) : null,
|
841 |
+
width: document.getElementById('width').value ? parseFloat(document.getElementById('width').value) : null,
|
842 |
+
utility: utilityValues.length > 0 ? utilityValues : [],
|
843 |
+
phenology_stages: phenologyValues.length > 0 ? phenologyValues : [],
|
844 |
+
storytelling_text: document.getElementById('storytellingText').value || null,
|
845 |
+
storytelling_audio: this.audioFile,
|
846 |
+
photographs: Object.keys(this.uploadedPhotos).length > 0 ? this.uploadedPhotos : null,
|
847 |
+
notes: document.getElementById('notes').value || null
|
848 |
+
};
|
849 |
+
|
850 |
+
try {
|
851 |
+
const response = await this.authenticatedFetch(`/api/trees/${treeId}`, {
|
852 |
+
method: 'PUT',
|
853 |
+
body: JSON.stringify(treeData)
|
854 |
+
});
|
855 |
+
|
856 |
+
if (!response) return;
|
857 |
+
|
858 |
+
if (response.ok) {
|
859 |
+
const result = await response.json();
|
860 |
+
this.showMessage(`Tree #${result.id} updated successfully!`, 'success');
|
861 |
+
this.cancelEdit(); // Exit edit mode
|
862 |
+
this.loadTrees(); // Refresh the tree list
|
863 |
+
} else {
|
864 |
+
const error = await response.json();
|
865 |
+
this.showMessage('Error updating tree: ' + (error.detail || 'Unknown error'), 'error');
|
866 |
+
}
|
867 |
+
} catch (error) {
|
868 |
+
console.error('Error updating tree:', error);
|
869 |
+
this.showMessage('Network error: ' + error.message, 'error');
|
870 |
+
}
|
871 |
+
}
|
872 |
+
|
873 |
+
async deleteTree(treeId) {
|
874 |
+
if (!confirm(`Are you sure you want to delete Tree #${treeId}? This action cannot be undone.`)) {
|
875 |
+
return;
|
876 |
+
}
|
877 |
+
|
878 |
+
try {
|
879 |
+
const response = await this.authenticatedFetch(`/api/trees/${treeId}`, {
|
880 |
+
method: 'DELETE'
|
881 |
+
});
|
882 |
+
|
883 |
+
if (!response) return;
|
884 |
+
|
885 |
+
if (response.ok) {
|
886 |
+
this.showMessage(`Tree #${treeId} deleted successfully.`, 'success');
|
887 |
+
this.loadTrees(); // Refresh the tree list
|
888 |
+
} else {
|
889 |
+
const error = await response.json();
|
890 |
+
this.showMessage('Error deleting tree: ' + (error.detail || 'Unknown error'), 'error');
|
891 |
+
}
|
892 |
+
} catch (error) {
|
893 |
+
console.error('Error deleting tree:', error);
|
894 |
+
this.showMessage('Network error: ' + error.message, 'error');
|
895 |
+
}
|
896 |
+
}
|
897 |
+
|
898 |
+
showMessage(message, type) {
|
899 |
+
const messageDiv = document.getElementById('message');
|
900 |
+
messageDiv.className = `message ${type === 'error' ? 'error' : 'success'}`;
|
901 |
+
messageDiv.textContent = message;
|
902 |
+
|
903 |
+
// Auto-hide after 5 seconds
|
904 |
+
setTimeout(() => {
|
905 |
+
messageDiv.textContent = '';
|
906 |
+
messageDiv.className = '';
|
907 |
+
}, 5000);
|
908 |
+
}
|
909 |
+
|
910 |
+
// Auto-suggestion functionality
|
911 |
+
async initializeAutoSuggestions() {
|
912 |
+
try {
|
913 |
+
// Load available tree codes for validation
|
914 |
+
const codesResponse = await this.authenticatedFetch('/api/tree-codes');
|
915 |
+
if (!codesResponse) return;
|
916 |
+
|
917 |
+
const codesData = await codesResponse.json();
|
918 |
+
this.availableTreeCodes = codesData.tree_codes || [];
|
919 |
+
|
920 |
+
// Setup autocomplete for tree identification fields
|
921 |
+
this.setupAutocomplete('localName', 'tree-suggestions');
|
922 |
+
this.setupAutocomplete('scientificName', 'tree-suggestions');
|
923 |
+
this.setupAutocomplete('commonName', 'tree-suggestions');
|
924 |
+
this.setupAutocomplete('treeCode', 'tree-codes');
|
925 |
+
|
926 |
+
} catch (error) {
|
927 |
+
console.error('Error initializing auto-suggestions:', error);
|
928 |
+
}
|
929 |
+
}
|
930 |
+
|
931 |
+
setupAutocomplete(fieldId, apiType) {
|
932 |
+
const input = document.getElementById(fieldId);
|
933 |
+
if (!input) return;
|
934 |
+
|
935 |
+
// Wrap input in container for dropdown positioning
|
936 |
+
if (!input.parentElement.classList.contains('autocomplete-container')) {
|
937 |
+
const container = document.createElement('div');
|
938 |
+
container.className = 'autocomplete-container';
|
939 |
+
input.parentNode.insertBefore(container, input);
|
940 |
+
container.appendChild(input);
|
941 |
+
|
942 |
+
// Create dropdown element
|
943 |
+
const dropdown = document.createElement('div');
|
944 |
+
dropdown.className = 'autocomplete-dropdown';
|
945 |
+
dropdown.id = `${fieldId}-dropdown`;
|
946 |
+
container.appendChild(dropdown);
|
947 |
+
}
|
948 |
+
|
949 |
+
// Add event listeners
|
950 |
+
input.addEventListener('input', (e) => this.handleInputChange(e, apiType));
|
951 |
+
input.addEventListener('keydown', (e) => this.handleKeyDown(e, fieldId));
|
952 |
+
input.addEventListener('blur', (e) => this.handleInputBlur(e, fieldId));
|
953 |
+
input.addEventListener('focus', (e) => this.handleInputFocus(e, fieldId));
|
954 |
+
}
|
955 |
+
|
956 |
+
async handleInputChange(event, apiType) {
|
957 |
+
const input = event.target;
|
958 |
+
const query = input.value.trim();
|
959 |
+
const fieldId = input.id;
|
960 |
+
|
961 |
+
// Clear previous timeout
|
962 |
+
if (this.searchTimeouts[fieldId]) {
|
963 |
+
clearTimeout(this.searchTimeouts[fieldId]);
|
964 |
+
}
|
965 |
+
|
966 |
+
if (query.length < 2) {
|
967 |
+
this.hideDropdown(fieldId);
|
968 |
+
return;
|
969 |
+
}
|
970 |
+
|
971 |
+
// Show loading state
|
972 |
+
this.showLoadingState(fieldId);
|
973 |
+
|
974 |
+
// Debounce search requests
|
975 |
+
this.searchTimeouts[fieldId] = setTimeout(async () => {
|
976 |
+
try {
|
977 |
+
let suggestions = [];
|
978 |
+
|
979 |
+
if (apiType === 'tree-codes') {
|
980 |
+
// Filter tree codes locally
|
981 |
+
suggestions = this.availableTreeCodes
|
982 |
+
.filter(code => code.toLowerCase().includes(query.toLowerCase()))
|
983 |
+
.slice(0, 10)
|
984 |
+
.map(code => ({
|
985 |
+
primary: code,
|
986 |
+
secondary: 'Tree Reference Code',
|
987 |
+
type: 'code'
|
988 |
+
}));
|
989 |
+
} else {
|
990 |
+
// Search tree suggestions from API
|
991 |
+
const response = await this.authenticatedFetch(`/api/tree-suggestions?query=${encodeURIComponent(query)}&limit=10`);
|
992 |
+
if (!response) return;
|
993 |
+
|
994 |
+
const data = await response.json();
|
995 |
+
|
996 |
+
if (data.suggestions) {
|
997 |
+
suggestions = data.suggestions.map(suggestion => ({
|
998 |
+
primary: this.getPrimaryText(suggestion, fieldId),
|
999 |
+
secondary: this.getSecondaryText(suggestion, fieldId),
|
1000 |
+
badges: this.getBadges(suggestion),
|
1001 |
+
data: suggestion
|
1002 |
+
}));
|
1003 |
+
}
|
1004 |
+
}
|
1005 |
+
|
1006 |
+
this.showSuggestions(fieldId, suggestions, query);
|
1007 |
+
|
1008 |
+
} catch (error) {
|
1009 |
+
console.error('Error fetching suggestions:', error);
|
1010 |
+
this.hideDropdown(fieldId);
|
1011 |
+
}
|
1012 |
+
}, 300); // 300ms debounce
|
1013 |
+
}
|
1014 |
+
|
1015 |
+
getPrimaryText(suggestion, fieldId) {
|
1016 |
+
switch (fieldId) {
|
1017 |
+
case 'localName':
|
1018 |
+
return suggestion.local_name || suggestion.scientific_name || suggestion.common_name;
|
1019 |
+
case 'scientificName':
|
1020 |
+
return suggestion.scientific_name || suggestion.local_name || suggestion.common_name;
|
1021 |
+
case 'commonName':
|
1022 |
+
return suggestion.common_name || suggestion.local_name || suggestion.scientific_name;
|
1023 |
+
default:
|
1024 |
+
return suggestion.local_name || suggestion.scientific_name || suggestion.common_name;
|
1025 |
+
}
|
1026 |
+
}
|
1027 |
+
|
1028 |
+
getSecondaryText(suggestion, fieldId) {
|
1029 |
+
const parts = [];
|
1030 |
+
|
1031 |
+
if (fieldId !== 'localName' && suggestion.local_name) {
|
1032 |
+
parts.push(`Local: ${suggestion.local_name}`);
|
1033 |
+
}
|
1034 |
+
if (fieldId !== 'scientificName' && suggestion.scientific_name) {
|
1035 |
+
parts.push(`Scientific: ${suggestion.scientific_name}`);
|
1036 |
+
}
|
1037 |
+
if (fieldId !== 'commonName' && suggestion.common_name) {
|
1038 |
+
parts.push(`Common: ${suggestion.common_name}`);
|
1039 |
+
}
|
1040 |
+
if (suggestion.tree_code) {
|
1041 |
+
parts.push(`Code: ${suggestion.tree_code}`);
|
1042 |
+
}
|
1043 |
+
|
1044 |
+
return parts.join(' β’ ');
|
1045 |
+
}
|
1046 |
+
|
1047 |
+
getBadges(suggestion) {
|
1048 |
+
const badges = [];
|
1049 |
+
if (suggestion.tree_code) {
|
1050 |
+
badges.push(suggestion.tree_code);
|
1051 |
+
}
|
1052 |
+
if (suggestion.fruiting_season) {
|
1053 |
+
badges.push(`Season: ${suggestion.fruiting_season}`);
|
1054 |
+
}
|
1055 |
+
return badges;
|
1056 |
+
}
|
1057 |
+
|
1058 |
+
showLoadingState(fieldId) {
|
1059 |
+
const dropdown = document.getElementById(`${fieldId}-dropdown`);
|
1060 |
+
if (dropdown) {
|
1061 |
+
dropdown.innerHTML = '<div class="autocomplete-loading">Searching...</div>';
|
1062 |
+
dropdown.style.display = 'block';
|
1063 |
+
this.activeDropdowns.add(fieldId);
|
1064 |
+
}
|
1065 |
+
}
|
1066 |
+
|
1067 |
+
showSuggestions(fieldId, suggestions, query) {
|
1068 |
+
const dropdown = document.getElementById(`${fieldId}-dropdown`);
|
1069 |
+
if (!dropdown) return;
|
1070 |
+
|
1071 |
+
if (suggestions.length === 0) {
|
1072 |
+
dropdown.innerHTML = '<div class="autocomplete-no-results">No matching suggestions found</div>';
|
1073 |
+
dropdown.style.display = 'block';
|
1074 |
+
this.activeDropdowns.add(fieldId);
|
1075 |
+
return;
|
1076 |
+
}
|
1077 |
+
|
1078 |
+
const html = suggestions.map((suggestion, index) => `
|
1079 |
+
<div class="autocomplete-item" data-index="${index}" data-field="${fieldId}">
|
1080 |
+
<div class="autocomplete-primary">${this.highlightMatch(suggestion.primary, query)}</div>
|
1081 |
+
${suggestion.secondary ? `<div class="autocomplete-secondary">${suggestion.secondary}</div>` : ''}
|
1082 |
+
${suggestion.badges && suggestion.badges.length > 0 ?
|
1083 |
+
`<div>${suggestion.badges.map(badge => `<span class="autocomplete-badge">${badge}</span>`).join('')}</div>` : ''}
|
1084 |
+
</div>
|
1085 |
+
`).join('');
|
1086 |
+
|
1087 |
+
dropdown.innerHTML = html;
|
1088 |
+
dropdown.style.display = 'block';
|
1089 |
+
this.activeDropdowns.add(fieldId);
|
1090 |
+
this.selectedIndex = -1;
|
1091 |
+
|
1092 |
+
// Add click listeners to suggestion items
|
1093 |
+
dropdown.querySelectorAll('.autocomplete-item').forEach(item => {
|
1094 |
+
item.addEventListener('mousedown', (e) => this.handleSuggestionClick(e, suggestions));
|
1095 |
+
});
|
1096 |
+
}
|
1097 |
+
|
1098 |
+
highlightMatch(text, query) {
|
1099 |
+
if (!query || !text) return text;
|
1100 |
+
|
1101 |
+
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
1102 |
+
return text.replace(regex, '<strong>$1</strong>');
|
1103 |
+
}
|
1104 |
+
|
1105 |
+
handleSuggestionClick(event, suggestions) {
|
1106 |
+
event.preventDefault();
|
1107 |
+
const item = event.target.closest('.autocomplete-item');
|
1108 |
+
const index = parseInt(item.dataset.index);
|
1109 |
+
const fieldId = item.dataset.field;
|
1110 |
+
const suggestion = suggestions[index];
|
1111 |
+
|
1112 |
+
this.applySuggestion(fieldId, suggestion);
|
1113 |
+
this.hideDropdown(fieldId);
|
1114 |
+
}
|
1115 |
+
|
1116 |
+
applySuggestion(fieldId, suggestion) {
|
1117 |
+
const input = document.getElementById(fieldId);
|
1118 |
+
|
1119 |
+
if (suggestion.type === 'code') {
|
1120 |
+
// Tree code suggestion
|
1121 |
+
input.value = suggestion.primary;
|
1122 |
+
} else {
|
1123 |
+
// Tree species suggestion - fill multiple fields
|
1124 |
+
const data = suggestion.data;
|
1125 |
+
|
1126 |
+
if (fieldId === 'localName' && data.local_name) {
|
1127 |
+
input.value = data.local_name;
|
1128 |
+
} else if (fieldId === 'scientificName' && data.scientific_name) {
|
1129 |
+
input.value = data.scientific_name;
|
1130 |
+
} else if (fieldId === 'commonName' && data.common_name) {
|
1131 |
+
input.value = data.common_name;
|
1132 |
+
} else {
|
1133 |
+
input.value = suggestion.primary;
|
1134 |
+
}
|
1135 |
+
|
1136 |
+
// Auto-fill other related fields if they're empty
|
1137 |
+
this.autoFillRelatedFields(data, fieldId);
|
1138 |
+
}
|
1139 |
+
|
1140 |
+
// Trigger input event for any validation
|
1141 |
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
1142 |
+
}
|
1143 |
+
|
1144 |
+
autoFillRelatedFields(data, excludeFieldId) {
|
1145 |
+
const fields = {
|
1146 |
+
'localName': data.local_name,
|
1147 |
+
'scientificName': data.scientific_name,
|
1148 |
+
'commonName': data.common_name,
|
1149 |
+
'treeCode': data.tree_code
|
1150 |
+
};
|
1151 |
+
|
1152 |
+
Object.entries(fields).forEach(([fieldId, value]) => {
|
1153 |
+
if (fieldId !== excludeFieldId && value) {
|
1154 |
+
const input = document.getElementById(fieldId);
|
1155 |
+
if (input && !input.value.trim()) {
|
1156 |
+
input.value = value;
|
1157 |
+
// Add visual indication that field was auto-filled
|
1158 |
+
input.style.backgroundColor = '#f0f9ff';
|
1159 |
+
setTimeout(() => {
|
1160 |
+
input.style.backgroundColor = '';
|
1161 |
+
}, 2000);
|
1162 |
+
}
|
1163 |
+
}
|
1164 |
+
});
|
1165 |
+
}
|
1166 |
+
|
1167 |
+
handleKeyDown(event, fieldId) {
|
1168 |
+
const dropdown = document.getElementById(`${fieldId}-dropdown`);
|
1169 |
+
if (!dropdown || dropdown.style.display === 'none') return;
|
1170 |
+
|
1171 |
+
const items = dropdown.querySelectorAll('.autocomplete-item');
|
1172 |
+
if (items.length === 0) return;
|
1173 |
+
|
1174 |
+
switch (event.key) {
|
1175 |
+
case 'ArrowDown':
|
1176 |
+
event.preventDefault();
|
1177 |
+
this.selectedIndex = Math.min(this.selectedIndex + 1, items.length - 1);
|
1178 |
+
this.updateHighlight(items);
|
1179 |
+
break;
|
1180 |
+
|
1181 |
+
case 'ArrowUp':
|
1182 |
+
event.preventDefault();
|
1183 |
+
this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
|
1184 |
+
this.updateHighlight(items);
|
1185 |
+
break;
|
1186 |
+
|
1187 |
+
case 'Enter':
|
1188 |
+
event.preventDefault();
|
1189 |
+
if (this.selectedIndex >= 0 && items[this.selectedIndex]) {
|
1190 |
+
items[this.selectedIndex].click();
|
1191 |
+
}
|
1192 |
+
break;
|
1193 |
+
|
1194 |
+
case 'Escape':
|
1195 |
+
event.preventDefault();
|
1196 |
+
this.hideDropdown(fieldId);
|
1197 |
+
break;
|
1198 |
+
}
|
1199 |
+
}
|
1200 |
+
|
1201 |
+
updateHighlight(items) {
|
1202 |
+
items.forEach((item, index) => {
|
1203 |
+
item.classList.toggle('highlighted', index === this.selectedIndex);
|
1204 |
+
});
|
1205 |
+
}
|
1206 |
+
|
1207 |
+
handleInputBlur(event, fieldId) {
|
1208 |
+
// Delay hiding to allow for click events on suggestions
|
1209 |
+
setTimeout(() => {
|
1210 |
+
this.hideDropdown(fieldId);
|
1211 |
+
}, 150);
|
1212 |
+
}
|
1213 |
+
|
1214 |
+
handleInputFocus(event, fieldId) {
|
1215 |
+
const input = event.target;
|
1216 |
+
if (input.value.length >= 2) {
|
1217 |
+
// Re-trigger search on focus if there's already content
|
1218 |
+
this.handleInputChange(event, fieldId === 'treeCode' ? 'tree-codes' : 'tree-suggestions');
|
1219 |
+
}
|
1220 |
+
}
|
1221 |
+
|
1222 |
+
hideDropdown(fieldId) {
|
1223 |
+
const dropdown = document.getElementById(`${fieldId}-dropdown`);
|
1224 |
+
if (dropdown) {
|
1225 |
+
dropdown.style.display = 'none';
|
1226 |
+
dropdown.innerHTML = '';
|
1227 |
+
this.activeDropdowns.delete(fieldId);
|
1228 |
+
this.selectedIndex = -1;
|
1229 |
+
}
|
1230 |
+
}
|
1231 |
+
|
1232 |
+
hideAllDropdowns() {
|
1233 |
+
this.activeDropdowns.forEach(fieldId => {
|
1234 |
+
this.hideDropdown(fieldId);
|
1235 |
+
});
|
1236 |
+
}
|
1237 |
+
}
|
1238 |
+
|
1239 |
+
// Initialize the app when the page loads
|
1240 |
+
let app;
|
1241 |
+
document.addEventListener('DOMContentLoaded', () => {
|
1242 |
+
app = new TreeTrackApp();
|
1243 |
+
});
|
@@ -80,26 +80,26 @@
|
|
80 |
-moz-osx-font-smoothing: grayscale;
|
81 |
}
|
82 |
|
83 |
-
/* Header */
|
84 |
.header {
|
85 |
-
background:
|
86 |
-
color:
|
87 |
position: sticky;
|
88 |
top: 0;
|
89 |
z-index: 100;
|
90 |
-
|
91 |
-
|
92 |
}
|
93 |
|
94 |
.header-content {
|
95 |
max-width: 1400px;
|
96 |
margin: 0 auto;
|
97 |
-
padding: var(--space-
|
98 |
display: flex;
|
99 |
justify-content: space-between;
|
100 |
align-items: center;
|
101 |
flex-wrap: wrap;
|
102 |
-
gap: var(--space-
|
103 |
}
|
104 |
|
105 |
.header-brand {
|
@@ -109,15 +109,20 @@
|
|
109 |
}
|
110 |
|
111 |
.header h1 {
|
112 |
-
font-size: 1.
|
113 |
-
font-weight:
|
114 |
margin: 0;
|
115 |
letter-spacing: -0.025em;
|
|
|
|
|
|
|
|
|
116 |
}
|
|
|
117 |
|
118 |
.header-subtitle {
|
119 |
-
font-size: 0.
|
120 |
-
|
121 |
font-weight: 400;
|
122 |
}
|
123 |
|
@@ -130,24 +135,24 @@
|
|
130 |
.user-info {
|
131 |
display: flex;
|
132 |
align-items: center;
|
133 |
-
gap: var(--space-
|
134 |
-
padding: var(--space-2) var(--space-
|
135 |
-
background:
|
136 |
-
border-radius: var(--radius-
|
137 |
-
border: 1px solid
|
138 |
-
backdrop-filter: blur(8px);
|
139 |
}
|
140 |
|
141 |
.user-avatar {
|
142 |
-
width:
|
143 |
-
height:
|
144 |
border-radius: 50%;
|
145 |
background: var(--primary-500);
|
146 |
display: flex;
|
147 |
align-items: center;
|
148 |
justify-content: center;
|
149 |
font-weight: 600;
|
150 |
-
font-size: 0.
|
|
|
151 |
}
|
152 |
|
153 |
.user-details {
|
@@ -157,13 +162,14 @@
|
|
157 |
}
|
158 |
|
159 |
.user-name {
|
160 |
-
font-size: 0.
|
161 |
-
font-weight:
|
|
|
162 |
}
|
163 |
|
164 |
.user-role {
|
165 |
-
font-size: 0.
|
166 |
-
|
167 |
text-transform: capitalize;
|
168 |
}
|
169 |
|
@@ -198,13 +204,16 @@
|
|
198 |
}
|
199 |
|
200 |
.btn-secondary {
|
201 |
-
background:
|
202 |
-
color:
|
203 |
-
border: 1px solid
|
|
|
|
|
204 |
}
|
205 |
|
206 |
.btn-secondary:hover {
|
207 |
-
background:
|
|
|
208 |
transform: translateY(-1px);
|
209 |
}
|
210 |
|
@@ -688,32 +697,105 @@
|
|
688 |
display: none;
|
689 |
}
|
690 |
|
691 |
-
/* Mobile
|
692 |
@media (max-width: 768px) {
|
693 |
.header-content {
|
694 |
padding: var(--space-3) var(--space-4);
|
|
|
|
|
|
|
695 |
}
|
696 |
|
697 |
.header h1 {
|
698 |
font-size: 1.5rem;
|
699 |
}
|
700 |
|
|
|
|
|
|
|
|
|
|
|
|
|
701 |
.main-container {
|
702 |
-
padding: var(--space-
|
703 |
}
|
704 |
|
705 |
.card-header,
|
706 |
.card-content {
|
707 |
-
padding: var(--space-4);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
708 |
}
|
709 |
|
710 |
.user-info {
|
711 |
padding: var(--space-2) var(--space-3);
|
|
|
712 |
}
|
713 |
|
714 |
.user-details {
|
715 |
display: none;
|
716 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
717 |
}
|
718 |
|
719 |
/* Focus visible for accessibility */
|
@@ -723,6 +805,254 @@
|
|
723 |
outline: 2px solid var(--primary-500);
|
724 |
outline-offset: 2px;
|
725 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
726 |
</style>
|
727 |
<script>
|
728 |
// Force refresh if we detect cached version
|
@@ -743,7 +1073,7 @@
|
|
743 |
<div class="header">
|
744 |
<div class="header-content">
|
745 |
<div class="header-brand">
|
746 |
-
<h1
|
747 |
<div class="header-subtitle">Professional Field Research Platform</div>
|
748 |
</div>
|
749 |
<div class="header-actions">
|
@@ -754,7 +1084,7 @@
|
|
754 |
<div class="user-role" id="userRole">User</div>
|
755 |
</div>
|
756 |
</div>
|
757 |
-
<a href="/static/map.html" class="btn btn-secondary"
|
758 |
<button id="logoutBtn" class="btn btn-secondary">Logout</button>
|
759 |
</div>
|
760 |
</div>
|
@@ -772,7 +1102,7 @@
|
|
772 |
<!-- Location Section -->
|
773 |
<div class="form-section">
|
774 |
<div class="section-header">
|
775 |
-
<h3 class="section-title"
|
776 |
<p class="section-description">Precise coordinates are essential for mapping and field research</p>
|
777 |
</div>
|
778 |
|
@@ -790,10 +1120,10 @@
|
|
790 |
<div class="form-group">
|
791 |
<div class="location-buttons">
|
792 |
<button type="button" id="getLocation" class="btn btn-outline location-btn-gps">
|
793 |
-
|
794 |
</button>
|
795 |
<a href="/static/map.html" class="btn btn-primary location-btn-map">
|
796 |
-
|
797 |
</a>
|
798 |
</div>
|
799 |
</div>
|
@@ -802,7 +1132,7 @@
|
|
802 |
<!-- Identification Section -->
|
803 |
<div class="form-section">
|
804 |
<div class="section-header">
|
805 |
-
<h3 class="section-title"
|
806 |
<p class="section-description">Botanical and local identification details</p>
|
807 |
</div>
|
808 |
|
@@ -830,7 +1160,7 @@
|
|
830 |
<!-- Measurements Section -->
|
831 |
<div class="form-section">
|
832 |
<div class="section-header">
|
833 |
-
<h3 class="section-title"
|
834 |
<p class="section-description">Quantitative assessment of tree dimensions</p>
|
835 |
</div>
|
836 |
|
@@ -849,7 +1179,7 @@
|
|
849 |
<!-- Utility Section -->
|
850 |
<div class="form-section">
|
851 |
<div class="section-header">
|
852 |
-
<h3 class="section-title"
|
853 |
<p class="section-description">Select all applicable ecological and cultural uses</p>
|
854 |
</div>
|
855 |
|
@@ -863,7 +1193,7 @@
|
|
863 |
<!-- Phenology Section -->
|
864 |
<div class="form-section">
|
865 |
<div class="section-header">
|
866 |
-
<h3 class="section-title"
|
867 |
<p class="section-description">Current developmental stages observed</p>
|
868 |
</div>
|
869 |
|
@@ -877,7 +1207,7 @@
|
|
877 |
<!-- Photography Section -->
|
878 |
<div class="form-section">
|
879 |
<div class="section-header">
|
880 |
-
<h3 class="section-title"
|
881 |
<p class="section-description">Upload photographs for different tree components</p>
|
882 |
</div>
|
883 |
|
@@ -889,7 +1219,7 @@
|
|
889 |
<!-- Cultural Documentation Section -->
|
890 |
<div class="form-section">
|
891 |
<div class="section-header">
|
892 |
-
<h3 class="section-title"
|
893 |
<p class="section-description">Stories, histories, and cultural significance</p>
|
894 |
</div>
|
895 |
|
@@ -901,7 +1231,7 @@
|
|
901 |
<div class="form-group">
|
902 |
<label class="form-label">Audio Recording</label>
|
903 |
<div class="file-upload-area" id="audioUpload">
|
904 |
-
<div class="file-upload-icon"
|
905 |
<div class="file-upload-text">Click to upload audio file</div>
|
906 |
<div class="file-upload-hint">Or drag and drop (MP3, WAV, M4A)</div>
|
907 |
</div>
|
@@ -912,7 +1242,7 @@
|
|
912 |
<!-- Field Notes Section -->
|
913 |
<div class="form-section">
|
914 |
<div class="section-header">
|
915 |
-
<h3 class="section-title"
|
916 |
<p class="section-description">Additional observations and remarks</p>
|
917 |
</div>
|
918 |
|
@@ -924,7 +1254,7 @@
|
|
924 |
|
925 |
<div class="form-actions">
|
926 |
<button type="button" id="resetForm" class="btn btn-outline">Reset Form</button>
|
927 |
-
<button type="submit" class="btn btn-primary btn-lg"
|
928 |
</div>
|
929 |
</form>
|
930 |
|
@@ -949,6 +1279,6 @@
|
|
949 |
</div>
|
950 |
</div>
|
951 |
|
952 |
-
<script src="/static/app.js?v=
|
953 |
</body>
|
954 |
</html>
|
|
|
80 |
-moz-osx-font-smoothing: grayscale;
|
81 |
}
|
82 |
|
83 |
+
/* Modern Header */
|
84 |
.header {
|
85 |
+
background: white;
|
86 |
+
color: var(--gray-900);
|
87 |
position: sticky;
|
88 |
top: 0;
|
89 |
z-index: 100;
|
90 |
+
border-bottom: 1px solid var(--gray-200);
|
91 |
+
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
92 |
}
|
93 |
|
94 |
.header-content {
|
95 |
max-width: 1400px;
|
96 |
margin: 0 auto;
|
97 |
+
padding: var(--space-3) var(--space-4);
|
98 |
display: flex;
|
99 |
justify-content: space-between;
|
100 |
align-items: center;
|
101 |
flex-wrap: wrap;
|
102 |
+
gap: var(--space-3);
|
103 |
}
|
104 |
|
105 |
.header-brand {
|
|
|
109 |
}
|
110 |
|
111 |
.header h1 {
|
112 |
+
font-size: 1.5rem;
|
113 |
+
font-weight: 600;
|
114 |
margin: 0;
|
115 |
letter-spacing: -0.025em;
|
116 |
+
color: var(--gray-900);
|
117 |
+
display: flex;
|
118 |
+
align-items: center;
|
119 |
+
gap: var(--space-2);
|
120 |
}
|
121 |
+
|
122 |
|
123 |
.header-subtitle {
|
124 |
+
font-size: 0.75rem;
|
125 |
+
color: var(--gray-600);
|
126 |
font-weight: 400;
|
127 |
}
|
128 |
|
|
|
135 |
.user-info {
|
136 |
display: flex;
|
137 |
align-items: center;
|
138 |
+
gap: var(--space-2);
|
139 |
+
padding: var(--space-2) var(--space-3);
|
140 |
+
background: var(--gray-50);
|
141 |
+
border-radius: var(--radius-lg);
|
142 |
+
border: 1px solid var(--gray-200);
|
|
|
143 |
}
|
144 |
|
145 |
.user-avatar {
|
146 |
+
width: 28px;
|
147 |
+
height: 28px;
|
148 |
border-radius: 50%;
|
149 |
background: var(--primary-500);
|
150 |
display: flex;
|
151 |
align-items: center;
|
152 |
justify-content: center;
|
153 |
font-weight: 600;
|
154 |
+
font-size: 0.75rem;
|
155 |
+
color: white;
|
156 |
}
|
157 |
|
158 |
.user-details {
|
|
|
162 |
}
|
163 |
|
164 |
.user-name {
|
165 |
+
font-size: 0.8125rem;
|
166 |
+
font-weight: 500;
|
167 |
+
color: var(--gray-900);
|
168 |
}
|
169 |
|
170 |
.user-role {
|
171 |
+
font-size: 0.6875rem;
|
172 |
+
color: var(--gray-600);
|
173 |
text-transform: capitalize;
|
174 |
}
|
175 |
|
|
|
204 |
}
|
205 |
|
206 |
.btn-secondary {
|
207 |
+
background: var(--gray-100);
|
208 |
+
color: var(--gray-700);
|
209 |
+
border: 1px solid var(--gray-200);
|
210 |
+
font-size: 0.8125rem;
|
211 |
+
padding: var(--space-2) var(--space-3);
|
212 |
}
|
213 |
|
214 |
.btn-secondary:hover {
|
215 |
+
background: var(--gray-200);
|
216 |
+
color: var(--gray-800);
|
217 |
transform: translateY(-1px);
|
218 |
}
|
219 |
|
|
|
697 |
display: none;
|
698 |
}
|
699 |
|
700 |
+
/* Improved Mobile Responsiveness */
|
701 |
@media (max-width: 768px) {
|
702 |
.header-content {
|
703 |
padding: var(--space-3) var(--space-4);
|
704 |
+
flex-direction: column;
|
705 |
+
align-items: stretch;
|
706 |
+
gap: var(--space-3);
|
707 |
}
|
708 |
|
709 |
.header h1 {
|
710 |
font-size: 1.5rem;
|
711 |
}
|
712 |
|
713 |
+
.header-actions {
|
714 |
+
flex-wrap: wrap;
|
715 |
+
justify-content: space-between;
|
716 |
+
width: 100%;
|
717 |
+
}
|
718 |
+
|
719 |
.main-container {
|
720 |
+
padding: var(--space-4) var(--space-3);
|
721 |
}
|
722 |
|
723 |
.card-header,
|
724 |
.card-content {
|
725 |
+
padding: var(--space-4) var(--space-3);
|
726 |
+
}
|
727 |
+
|
728 |
+
.card-title {
|
729 |
+
font-size: 1.125rem;
|
730 |
+
}
|
731 |
+
|
732 |
+
.section-title {
|
733 |
+
font-size: 1rem;
|
734 |
}
|
735 |
|
736 |
.user-info {
|
737 |
padding: var(--space-2) var(--space-3);
|
738 |
+
flex-shrink: 0;
|
739 |
}
|
740 |
|
741 |
.user-details {
|
742 |
display: none;
|
743 |
}
|
744 |
+
|
745 |
+
/* Form improvements for mobile */
|
746 |
+
.form-input,
|
747 |
+
.form-textarea {
|
748 |
+
font-size: 16px; /* Prevent zoom on iOS */
|
749 |
+
padding: var(--space-3) var(--space-3);
|
750 |
+
}
|
751 |
+
|
752 |
+
.form-label {
|
753 |
+
font-size: 0.8125rem;
|
754 |
+
margin-bottom: var(--space-1);
|
755 |
+
}
|
756 |
+
|
757 |
+
.multi-select {
|
758 |
+
max-height: 150px;
|
759 |
+
}
|
760 |
+
|
761 |
+
.multi-select label {
|
762 |
+
padding: var(--space-2) var(--space-3);
|
763 |
+
margin: var(--space-1);
|
764 |
+
font-size: 0.8125rem;
|
765 |
+
}
|
766 |
+
}
|
767 |
+
|
768 |
+
@media (max-width: 480px) {
|
769 |
+
.header h1 {
|
770 |
+
font-size: 1.25rem;
|
771 |
+
}
|
772 |
+
|
773 |
+
.header-subtitle {
|
774 |
+
font-size: 0.75rem;
|
775 |
+
}
|
776 |
+
|
777 |
+
.main-container {
|
778 |
+
padding: var(--space-3) var(--space-2);
|
779 |
+
}
|
780 |
+
|
781 |
+
.card {
|
782 |
+
border-radius: var(--radius-lg);
|
783 |
+
}
|
784 |
+
|
785 |
+
.card-header,
|
786 |
+
.card-content {
|
787 |
+
padding: var(--space-3) var(--space-2);
|
788 |
+
}
|
789 |
+
|
790 |
+
.btn {
|
791 |
+
padding: var(--space-2) var(--space-3);
|
792 |
+
font-size: 0.8125rem;
|
793 |
+
}
|
794 |
+
|
795 |
+
.btn-lg {
|
796 |
+
padding: var(--space-3) var(--space-4);
|
797 |
+
font-size: 0.875rem;
|
798 |
+
}
|
799 |
}
|
800 |
|
801 |
/* Focus visible for accessibility */
|
|
|
805 |
outline: 2px solid var(--primary-500);
|
806 |
outline-offset: 2px;
|
807 |
}
|
808 |
+
|
809 |
+
/* Autocomplete Dropdown Styles */
|
810 |
+
.autocomplete-container {
|
811 |
+
position: relative;
|
812 |
+
}
|
813 |
+
|
814 |
+
.autocomplete-dropdown {
|
815 |
+
position: absolute;
|
816 |
+
top: 100%;
|
817 |
+
left: 0;
|
818 |
+
right: 0;
|
819 |
+
z-index: 1000;
|
820 |
+
background: white;
|
821 |
+
border: 1px solid var(--gray-300);
|
822 |
+
border-top: none;
|
823 |
+
border-radius: 0 0 var(--radius-md) var(--radius-md);
|
824 |
+
max-height: 200px;
|
825 |
+
overflow-y: auto;
|
826 |
+
box-shadow: var(--shadow-lg);
|
827 |
+
display: none;
|
828 |
+
}
|
829 |
+
|
830 |
+
.autocomplete-item {
|
831 |
+
padding: var(--space-3) var(--space-4);
|
832 |
+
cursor: pointer;
|
833 |
+
border-bottom: 1px solid var(--gray-100);
|
834 |
+
transition: all 0.15s ease;
|
835 |
+
}
|
836 |
+
|
837 |
+
.autocomplete-item:hover,
|
838 |
+
.autocomplete-item.highlighted {
|
839 |
+
background: var(--primary-50);
|
840 |
+
}
|
841 |
+
|
842 |
+
.autocomplete-item:last-child {
|
843 |
+
border-bottom: none;
|
844 |
+
}
|
845 |
+
|
846 |
+
.autocomplete-primary {
|
847 |
+
font-size: 0.875rem;
|
848 |
+
font-weight: 500;
|
849 |
+
color: var(--gray-900);
|
850 |
+
margin-bottom: var(--space-1);
|
851 |
+
}
|
852 |
+
|
853 |
+
.autocomplete-secondary {
|
854 |
+
font-size: 0.75rem;
|
855 |
+
color: var(--gray-600);
|
856 |
+
margin-bottom: var(--space-2);
|
857 |
+
}
|
858 |
+
|
859 |
+
.autocomplete-badge {
|
860 |
+
display: inline-block;
|
861 |
+
background: var(--primary-100);
|
862 |
+
color: var(--primary-700);
|
863 |
+
padding: var(--space-1) var(--space-2);
|
864 |
+
border-radius: var(--radius-sm);
|
865 |
+
font-size: 0.75rem;
|
866 |
+
font-weight: 500;
|
867 |
+
margin-right: var(--space-1);
|
868 |
+
margin-bottom: var(--space-1);
|
869 |
+
}
|
870 |
+
|
871 |
+
.autocomplete-loading,
|
872 |
+
.autocomplete-no-results {
|
873 |
+
padding: var(--space-3) var(--space-4);
|
874 |
+
text-align: center;
|
875 |
+
color: var(--gray-500);
|
876 |
+
font-size: 0.875rem;
|
877 |
+
}
|
878 |
+
|
879 |
+
/* Improved Tree Item Actions */
|
880 |
+
.tree-actions {
|
881 |
+
display: flex;
|
882 |
+
gap: var(--space-1);
|
883 |
+
flex-shrink: 0;
|
884 |
+
}
|
885 |
+
|
886 |
+
.btn-icon {
|
887 |
+
padding: var(--space-1) var(--space-2);
|
888 |
+
font-size: 0.75rem;
|
889 |
+
font-weight: 500;
|
890 |
+
min-width: 44px; /* Ensure touch-friendly minimum */
|
891 |
+
height: 32px;
|
892 |
+
border: 1px solid var(--gray-300);
|
893 |
+
background: white;
|
894 |
+
border-radius: var(--radius-sm);
|
895 |
+
text-align: center;
|
896 |
+
transition: all 0.15s ease;
|
897 |
+
}
|
898 |
+
|
899 |
+
.btn-icon.edit-tree {
|
900 |
+
color: var(--primary-600);
|
901 |
+
border-color: var(--primary-200);
|
902 |
+
}
|
903 |
+
|
904 |
+
.btn-icon.edit-tree:hover {
|
905 |
+
background: var(--primary-50);
|
906 |
+
border-color: var(--primary-300);
|
907 |
+
color: var(--primary-700);
|
908 |
+
}
|
909 |
+
|
910 |
+
.btn-icon.delete-tree {
|
911 |
+
color: var(--red-600);
|
912 |
+
border-color: var(--red-200);
|
913 |
+
}
|
914 |
+
|
915 |
+
.btn-icon.delete-tree:hover {
|
916 |
+
background: var(--red-50);
|
917 |
+
border-color: var(--red-300);
|
918 |
+
color: var(--red-700);
|
919 |
+
}
|
920 |
+
|
921 |
+
/* Mobile Improvements for Tree Items */
|
922 |
+
@media (max-width: 768px) {
|
923 |
+
.tree-header {
|
924 |
+
flex-direction: column;
|
925 |
+
align-items: flex-start;
|
926 |
+
gap: var(--space-2);
|
927 |
+
}
|
928 |
+
|
929 |
+
.tree-actions {
|
930 |
+
align-self: flex-end;
|
931 |
+
gap: var(--space-2);
|
932 |
+
}
|
933 |
+
|
934 |
+
.btn-icon {
|
935 |
+
min-width: 48px;
|
936 |
+
height: 36px;
|
937 |
+
font-size: 0.8125rem;
|
938 |
+
}
|
939 |
+
}
|
940 |
+
|
941 |
+
/* Enhanced Photo Category Styling */
|
942 |
+
.photo-category {
|
943 |
+
margin-bottom: var(--space-6);
|
944 |
+
padding: var(--space-5);
|
945 |
+
border: 2px solid var(--gray-200);
|
946 |
+
border-radius: var(--radius-xl);
|
947 |
+
background: white;
|
948 |
+
box-shadow: var(--shadow-sm);
|
949 |
+
transition: all 0.2s ease;
|
950 |
+
}
|
951 |
+
|
952 |
+
.photo-category:hover {
|
953 |
+
border-color: var(--primary-200);
|
954 |
+
box-shadow: var(--shadow-md);
|
955 |
+
}
|
956 |
+
|
957 |
+
.photo-category-header {
|
958 |
+
display: flex;
|
959 |
+
justify-content: space-between;
|
960 |
+
align-items: center;
|
961 |
+
margin-bottom: var(--space-4);
|
962 |
+
padding-bottom: var(--space-3);
|
963 |
+
border-bottom: 1px solid var(--gray-100);
|
964 |
+
}
|
965 |
+
|
966 |
+
.photo-category-title {
|
967 |
+
font-size: 1rem;
|
968 |
+
font-weight: 600;
|
969 |
+
color: var(--gray-800);
|
970 |
+
display: flex;
|
971 |
+
align-items: center;
|
972 |
+
gap: var(--space-2);
|
973 |
+
}
|
974 |
+
|
975 |
+
.photo-category-icon {
|
976 |
+
width: 20px;
|
977 |
+
height: 20px;
|
978 |
+
background: var(--primary-100);
|
979 |
+
border-radius: var(--radius-sm);
|
980 |
+
display: flex;
|
981 |
+
align-items: center;
|
982 |
+
justify-content: center;
|
983 |
+
font-size: 0.75rem;
|
984 |
+
}
|
985 |
+
|
986 |
+
.photo-upload-area {
|
987 |
+
display: flex;
|
988 |
+
gap: var(--space-3);
|
989 |
+
}
|
990 |
+
|
991 |
+
.photo-upload {
|
992 |
+
flex: 1;
|
993 |
+
background: var(--gray-50);
|
994 |
+
border: 2px dashed var(--gray-300);
|
995 |
+
border-radius: var(--radius-lg);
|
996 |
+
padding: var(--space-4);
|
997 |
+
text-align: center;
|
998 |
+
cursor: pointer;
|
999 |
+
color: var(--gray-600);
|
1000 |
+
font-size: 0.875rem;
|
1001 |
+
transition: all 0.2s ease;
|
1002 |
+
min-height: 80px;
|
1003 |
+
display: flex;
|
1004 |
+
flex-direction: column;
|
1005 |
+
justify-content: center;
|
1006 |
+
align-items: center;
|
1007 |
+
gap: var(--space-2);
|
1008 |
+
}
|
1009 |
+
|
1010 |
+
.photo-upload:hover {
|
1011 |
+
border-color: var(--primary-400);
|
1012 |
+
background: var(--primary-50);
|
1013 |
+
color: var(--primary-600);
|
1014 |
+
}
|
1015 |
+
|
1016 |
+
.photo-upload-icon {
|
1017 |
+
font-size: 1.5rem;
|
1018 |
+
opacity: 0.6;
|
1019 |
+
}
|
1020 |
+
|
1021 |
+
.camera-btn {
|
1022 |
+
padding: var(--space-2) var(--space-4);
|
1023 |
+
background: var(--primary-500);
|
1024 |
+
color: white;
|
1025 |
+
border: none;
|
1026 |
+
border-radius: var(--radius-md);
|
1027 |
+
font-size: 0.8125rem;
|
1028 |
+
font-weight: 500;
|
1029 |
+
cursor: pointer;
|
1030 |
+
transition: all 0.2s ease;
|
1031 |
+
min-width: 80px;
|
1032 |
+
}
|
1033 |
+
|
1034 |
+
.camera-btn:hover {
|
1035 |
+
background: var(--primary-600);
|
1036 |
+
transform: translateY(-1px);
|
1037 |
+
}
|
1038 |
+
|
1039 |
+
.uploaded-file {
|
1040 |
+
background: var(--green-50);
|
1041 |
+
color: var(--green-700);
|
1042 |
+
padding: var(--space-2) var(--space-3);
|
1043 |
+
border-radius: var(--radius-md);
|
1044 |
+
font-size: 0.8125rem;
|
1045 |
+
margin-top: var(--space-3);
|
1046 |
+
border: 1px solid var(--green-200);
|
1047 |
+
display: flex;
|
1048 |
+
align-items: center;
|
1049 |
+
gap: var(--space-2);
|
1050 |
+
}
|
1051 |
+
|
1052 |
+
.uploaded-file::before {
|
1053 |
+
content: 'β';
|
1054 |
+
font-weight: bold;
|
1055 |
+
}
|
1056 |
</style>
|
1057 |
<script>
|
1058 |
// Force refresh if we detect cached version
|
|
|
1073 |
<div class="header">
|
1074 |
<div class="header-content">
|
1075 |
<div class="header-brand">
|
1076 |
+
<h1>TreeTrack</h1>
|
1077 |
<div class="header-subtitle">Professional Field Research Platform</div>
|
1078 |
</div>
|
1079 |
<div class="header-actions">
|
|
|
1084 |
<div class="user-role" id="userRole">User</div>
|
1085 |
</div>
|
1086 |
</div>
|
1087 |
+
<a href="/static/map.html" class="btn btn-secondary">View Map</a>
|
1088 |
<button id="logoutBtn" class="btn btn-secondary">Logout</button>
|
1089 |
</div>
|
1090 |
</div>
|
|
|
1102 |
<!-- Location Section -->
|
1103 |
<div class="form-section">
|
1104 |
<div class="section-header">
|
1105 |
+
<h3 class="section-title">Geographic Location</h3>
|
1106 |
<p class="section-description">Precise coordinates are essential for mapping and field research</p>
|
1107 |
</div>
|
1108 |
|
|
|
1120 |
<div class="form-group">
|
1121 |
<div class="location-buttons">
|
1122 |
<button type="button" id="getLocation" class="btn btn-outline location-btn-gps">
|
1123 |
+
<span class="btn-text">Get GPS Location</span>
|
1124 |
</button>
|
1125 |
<a href="/static/map.html" class="btn btn-primary location-btn-map">
|
1126 |
+
<span class="btn-text">Select from Map</span>
|
1127 |
</a>
|
1128 |
</div>
|
1129 |
</div>
|
|
|
1132 |
<!-- Identification Section -->
|
1133 |
<div class="form-section">
|
1134 |
<div class="section-header">
|
1135 |
+
<h3 class="section-title">Tree Identification</h3>
|
1136 |
<p class="section-description">Botanical and local identification details</p>
|
1137 |
</div>
|
1138 |
|
|
|
1160 |
<!-- Measurements Section -->
|
1161 |
<div class="form-section">
|
1162 |
<div class="section-header">
|
1163 |
+
<h3 class="section-title">Physical Measurements</h3>
|
1164 |
<p class="section-description">Quantitative assessment of tree dimensions</p>
|
1165 |
</div>
|
1166 |
|
|
|
1179 |
<!-- Utility Section -->
|
1180 |
<div class="form-section">
|
1181 |
<div class="section-header">
|
1182 |
+
<h3 class="section-title">Ecological & Cultural Utility</h3>
|
1183 |
<p class="section-description">Select all applicable ecological and cultural uses</p>
|
1184 |
</div>
|
1185 |
|
|
|
1193 |
<!-- Phenology Section -->
|
1194 |
<div class="form-section">
|
1195 |
<div class="section-header">
|
1196 |
+
<h3 class="section-title">Phenology Assessment</h3>
|
1197 |
<p class="section-description">Current developmental stages observed</p>
|
1198 |
</div>
|
1199 |
|
|
|
1207 |
<!-- Photography Section -->
|
1208 |
<div class="form-section">
|
1209 |
<div class="section-header">
|
1210 |
+
<h3 class="section-title">Photographic Documentation</h3>
|
1211 |
<p class="section-description">Upload photographs for different tree components</p>
|
1212 |
</div>
|
1213 |
|
|
|
1219 |
<!-- Cultural Documentation Section -->
|
1220 |
<div class="form-section">
|
1221 |
<div class="section-header">
|
1222 |
+
<h3 class="section-title">Cultural Documentation</h3>
|
1223 |
<p class="section-description">Stories, histories, and cultural significance</p>
|
1224 |
</div>
|
1225 |
|
|
|
1231 |
<div class="form-group">
|
1232 |
<label class="form-label">Audio Recording</label>
|
1233 |
<div class="file-upload-area" id="audioUpload">
|
1234 |
+
<div class="file-upload-icon">MIC</div>
|
1235 |
<div class="file-upload-text">Click to upload audio file</div>
|
1236 |
<div class="file-upload-hint">Or drag and drop (MP3, WAV, M4A)</div>
|
1237 |
</div>
|
|
|
1242 |
<!-- Field Notes Section -->
|
1243 |
<div class="form-section">
|
1244 |
<div class="section-header">
|
1245 |
+
<h3 class="section-title">Field Notes</h3>
|
1246 |
<p class="section-description">Additional observations and remarks</p>
|
1247 |
</div>
|
1248 |
|
|
|
1254 |
|
1255 |
<div class="form-actions">
|
1256 |
<button type="button" id="resetForm" class="btn btn-outline">Reset Form</button>
|
1257 |
+
<button type="submit" class="btn btn-primary btn-lg">Save Tree Record</button>
|
1258 |
</div>
|
1259 |
</form>
|
1260 |
|
|
|
1279 |
</div>
|
1280 |
</div>
|
1281 |
|
1282 |
+
<script type="module" src="/static/js/tree-track-app.js?v=5.0.0"></script>
|
1283 |
</body>
|
1284 |
</html>
|
@@ -0,0 +1,179 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* API Client Module
|
3 |
+
* Handles all API communication with authentication
|
4 |
+
*/
|
5 |
+
export class ApiClient {
|
6 |
+
constructor(authManager) {
|
7 |
+
this.authManager = authManager;
|
8 |
+
}
|
9 |
+
|
10 |
+
async authenticatedFetch(url, options = {}) {
|
11 |
+
const headers = {
|
12 |
+
...this.authManager.getAuthHeaders(),
|
13 |
+
...options.headers
|
14 |
+
};
|
15 |
+
|
16 |
+
const response = await fetch(url, {
|
17 |
+
...options,
|
18 |
+
headers
|
19 |
+
});
|
20 |
+
|
21 |
+
if (response.status === 401) {
|
22 |
+
// Token expired or invalid
|
23 |
+
this.authManager.clearAuthData();
|
24 |
+
window.location.href = '/login';
|
25 |
+
return null;
|
26 |
+
}
|
27 |
+
|
28 |
+
return response;
|
29 |
+
}
|
30 |
+
|
31 |
+
async loadFormOptions() {
|
32 |
+
try {
|
33 |
+
const [utilityResponse, phenologyResponse, categoriesResponse] = await Promise.all([
|
34 |
+
this.authenticatedFetch('/api/utilities'),
|
35 |
+
this.authenticatedFetch('/api/phenology-stages'),
|
36 |
+
this.authenticatedFetch('/api/photo-categories')
|
37 |
+
]);
|
38 |
+
|
39 |
+
if (!utilityResponse || !phenologyResponse || !categoriesResponse) {
|
40 |
+
throw new Error('Failed to load form options');
|
41 |
+
}
|
42 |
+
|
43 |
+
const [utilityData, phenologyData, categoriesData] = await Promise.all([
|
44 |
+
utilityResponse.json(),
|
45 |
+
phenologyResponse.json(),
|
46 |
+
categoriesResponse.json()
|
47 |
+
]);
|
48 |
+
|
49 |
+
return {
|
50 |
+
utilities: utilityData.utilities,
|
51 |
+
phenologyStages: phenologyData.stages,
|
52 |
+
photoCategories: categoriesData.categories
|
53 |
+
};
|
54 |
+
} catch (error) {
|
55 |
+
console.error('Error loading form options:', error);
|
56 |
+
throw error;
|
57 |
+
}
|
58 |
+
}
|
59 |
+
|
60 |
+
async saveTree(treeData) {
|
61 |
+
const response = await this.authenticatedFetch('/api/trees', {
|
62 |
+
method: 'POST',
|
63 |
+
body: JSON.stringify(treeData)
|
64 |
+
});
|
65 |
+
|
66 |
+
if (!response) return null;
|
67 |
+
|
68 |
+
if (!response.ok) {
|
69 |
+
const error = await response.json();
|
70 |
+
throw new Error(error.detail || 'Unknown error');
|
71 |
+
}
|
72 |
+
|
73 |
+
return await response.json();
|
74 |
+
}
|
75 |
+
|
76 |
+
async updateTree(treeId, treeData) {
|
77 |
+
const response = await this.authenticatedFetch(`/api/trees/${treeId}`, {
|
78 |
+
method: 'PUT',
|
79 |
+
body: JSON.stringify(treeData)
|
80 |
+
});
|
81 |
+
|
82 |
+
if (!response) return null;
|
83 |
+
|
84 |
+
if (!response.ok) {
|
85 |
+
const error = await response.json();
|
86 |
+
throw new Error(error.detail || 'Unknown error');
|
87 |
+
}
|
88 |
+
|
89 |
+
return await response.json();
|
90 |
+
}
|
91 |
+
|
92 |
+
async deleteTree(treeId) {
|
93 |
+
const response = await this.authenticatedFetch(`/api/trees/${treeId}`, {
|
94 |
+
method: 'DELETE'
|
95 |
+
});
|
96 |
+
|
97 |
+
if (!response) return null;
|
98 |
+
|
99 |
+
if (!response.ok) {
|
100 |
+
const error = await response.json();
|
101 |
+
throw new Error(error.detail || 'Unknown error');
|
102 |
+
}
|
103 |
+
|
104 |
+
return true;
|
105 |
+
}
|
106 |
+
|
107 |
+
async loadTrees(limit = 20) {
|
108 |
+
const response = await this.authenticatedFetch(`/api/trees?limit=${limit}`);
|
109 |
+
if (!response) return [];
|
110 |
+
|
111 |
+
if (!response.ok) {
|
112 |
+
throw new Error('Failed to load trees');
|
113 |
+
}
|
114 |
+
|
115 |
+
return await response.json();
|
116 |
+
}
|
117 |
+
|
118 |
+
async loadTree(treeId) {
|
119 |
+
const response = await this.authenticatedFetch(`/api/trees/${treeId}`);
|
120 |
+
if (!response) return null;
|
121 |
+
|
122 |
+
if (!response.ok) {
|
123 |
+
throw new Error('Failed to fetch tree data');
|
124 |
+
}
|
125 |
+
|
126 |
+
return await response.json();
|
127 |
+
}
|
128 |
+
|
129 |
+
async loadTreeCodes() {
|
130 |
+
const response = await this.authenticatedFetch('/api/tree-codes');
|
131 |
+
if (!response) return [];
|
132 |
+
|
133 |
+
if (!response.ok) {
|
134 |
+
throw new Error('Failed to load tree codes');
|
135 |
+
}
|
136 |
+
|
137 |
+
const data = await response.json();
|
138 |
+
return data.tree_codes || [];
|
139 |
+
}
|
140 |
+
|
141 |
+
async searchTreeSuggestions(query, limit = 10) {
|
142 |
+
const response = await this.authenticatedFetch(
|
143 |
+
`/api/tree-suggestions?query=${encodeURIComponent(query)}&limit=${limit}`
|
144 |
+
);
|
145 |
+
|
146 |
+
if (!response) return [];
|
147 |
+
|
148 |
+
if (!response.ok) {
|
149 |
+
throw new Error('Failed to search tree suggestions');
|
150 |
+
}
|
151 |
+
|
152 |
+
const data = await response.json();
|
153 |
+
return data.suggestions || [];
|
154 |
+
}
|
155 |
+
|
156 |
+
async uploadFile(file, type, category = null) {
|
157 |
+
const formData = new FormData();
|
158 |
+
formData.append('file', file);
|
159 |
+
if (category) {
|
160 |
+
formData.append('category', category);
|
161 |
+
}
|
162 |
+
|
163 |
+
const endpoint = type === 'image' ? '/api/upload/image' : '/api/upload/audio';
|
164 |
+
|
165 |
+
const response = await fetch(endpoint, {
|
166 |
+
method: 'POST',
|
167 |
+
headers: {
|
168 |
+
'Authorization': `Bearer ${this.authManager.authToken}`
|
169 |
+
},
|
170 |
+
body: formData
|
171 |
+
});
|
172 |
+
|
173 |
+
if (!response.ok) {
|
174 |
+
throw new Error('Upload failed');
|
175 |
+
}
|
176 |
+
|
177 |
+
return await response.json();
|
178 |
+
}
|
179 |
+
}
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Authentication Manager Module
|
3 |
+
* Handles user authentication, session management, and permissions
|
4 |
+
*/
|
5 |
+
export class AuthManager {
|
6 |
+
constructor() {
|
7 |
+
this.currentUser = null;
|
8 |
+
this.authToken = null;
|
9 |
+
}
|
10 |
+
|
11 |
+
async checkAuthentication() {
|
12 |
+
const token = localStorage.getItem('auth_token');
|
13 |
+
if (!token) {
|
14 |
+
return false;
|
15 |
+
}
|
16 |
+
|
17 |
+
try {
|
18 |
+
const response = await fetch('/api/auth/validate', {
|
19 |
+
headers: {
|
20 |
+
'Authorization': `Bearer ${token}`
|
21 |
+
}
|
22 |
+
});
|
23 |
+
|
24 |
+
if (response.ok) {
|
25 |
+
const data = await response.json();
|
26 |
+
this.currentUser = data.user;
|
27 |
+
this.authToken = token;
|
28 |
+
return true;
|
29 |
+
} else {
|
30 |
+
this.clearAuthData();
|
31 |
+
return false;
|
32 |
+
}
|
33 |
+
} catch (error) {
|
34 |
+
console.error('Auth validation error:', error);
|
35 |
+
return false;
|
36 |
+
}
|
37 |
+
}
|
38 |
+
|
39 |
+
async logout() {
|
40 |
+
try {
|
41 |
+
await fetch('/api/auth/logout', {
|
42 |
+
method: 'POST',
|
43 |
+
headers: {
|
44 |
+
'Authorization': `Bearer ${this.authToken}`
|
45 |
+
}
|
46 |
+
});
|
47 |
+
} catch (error) {
|
48 |
+
console.error('Logout error:', error);
|
49 |
+
} finally {
|
50 |
+
this.clearAuthData();
|
51 |
+
window.location.href = '/login';
|
52 |
+
}
|
53 |
+
}
|
54 |
+
|
55 |
+
clearAuthData() {
|
56 |
+
localStorage.removeItem('auth_token');
|
57 |
+
localStorage.removeItem('user_info');
|
58 |
+
this.currentUser = null;
|
59 |
+
this.authToken = null;
|
60 |
+
}
|
61 |
+
|
62 |
+
canEditTree(createdBy) {
|
63 |
+
if (!this.currentUser) return false;
|
64 |
+
|
65 |
+
const permissions = this.currentUser.permissions || [];
|
66 |
+
|
67 |
+
// Admin and system can edit any tree
|
68 |
+
if (permissions.includes('admin') || permissions.includes('system')) {
|
69 |
+
return true;
|
70 |
+
}
|
71 |
+
|
72 |
+
// Users can edit trees they created
|
73 |
+
if (permissions.includes('edit_own') && createdBy === this.currentUser.username) {
|
74 |
+
return true;
|
75 |
+
}
|
76 |
+
|
77 |
+
// Users with delete permission can edit any tree
|
78 |
+
if (permissions.includes('delete')) {
|
79 |
+
return true;
|
80 |
+
}
|
81 |
+
|
82 |
+
return false;
|
83 |
+
}
|
84 |
+
|
85 |
+
canDeleteTree(createdBy) {
|
86 |
+
if (!this.currentUser) return false;
|
87 |
+
|
88 |
+
const permissions = this.currentUser.permissions || [];
|
89 |
+
|
90 |
+
// Only admin and system can delete trees
|
91 |
+
if (permissions.includes('admin') || permissions.includes('system')) {
|
92 |
+
return true;
|
93 |
+
}
|
94 |
+
|
95 |
+
// Users with explicit delete permission
|
96 |
+
if (permissions.includes('delete')) {
|
97 |
+
return true;
|
98 |
+
}
|
99 |
+
|
100 |
+
return false;
|
101 |
+
}
|
102 |
+
|
103 |
+
getAuthHeaders() {
|
104 |
+
return {
|
105 |
+
'Content-Type': 'application/json',
|
106 |
+
'Authorization': `Bearer ${this.authToken}`
|
107 |
+
};
|
108 |
+
}
|
109 |
+
}
|
@@ -0,0 +1,345 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* AutoComplete Manager Module
|
3 |
+
* Handles intelligent auto-suggestions and field auto-completion
|
4 |
+
*/
|
5 |
+
export class AutoCompleteManager {
|
6 |
+
constructor(apiClient, uiManager) {
|
7 |
+
this.apiClient = apiClient;
|
8 |
+
this.uiManager = uiManager;
|
9 |
+
this.searchTimeouts = {};
|
10 |
+
this.activeDropdowns = new Set();
|
11 |
+
this.selectedIndex = -1;
|
12 |
+
this.availableTreeCodes = [];
|
13 |
+
}
|
14 |
+
|
15 |
+
async initialize() {
|
16 |
+
try {
|
17 |
+
this.availableTreeCodes = await this.apiClient.loadTreeCodes();
|
18 |
+
this.setupAutocomplete('localName', 'tree-suggestions');
|
19 |
+
this.setupAutocomplete('scientificName', 'tree-suggestions');
|
20 |
+
this.setupAutocomplete('commonName', 'tree-suggestions');
|
21 |
+
this.setupAutocomplete('treeCode', 'tree-codes');
|
22 |
+
} catch (error) {
|
23 |
+
console.error('Error initializing auto-suggestions:', error);
|
24 |
+
}
|
25 |
+
}
|
26 |
+
|
27 |
+
setupAutocomplete(fieldId, apiType) {
|
28 |
+
const input = document.getElementById(fieldId);
|
29 |
+
if (!input) return;
|
30 |
+
|
31 |
+
this.createAutocompleteContainer(input, fieldId);
|
32 |
+
this.attachEventListeners(input, fieldId, apiType);
|
33 |
+
}
|
34 |
+
|
35 |
+
createAutocompleteContainer(input, fieldId) {
|
36 |
+
if (input.parentElement.classList.contains('autocomplete-container')) return;
|
37 |
+
|
38 |
+
const container = document.createElement('div');
|
39 |
+
container.className = 'autocomplete-container';
|
40 |
+
input.parentNode.insertBefore(container, input);
|
41 |
+
container.appendChild(input);
|
42 |
+
|
43 |
+
const dropdown = document.createElement('div');
|
44 |
+
dropdown.className = 'autocomplete-dropdown';
|
45 |
+
dropdown.id = `${fieldId}-dropdown`;
|
46 |
+
container.appendChild(dropdown);
|
47 |
+
}
|
48 |
+
|
49 |
+
attachEventListeners(input, fieldId, apiType) {
|
50 |
+
input.addEventListener('input', (e) => this.handleInputChange(e, apiType));
|
51 |
+
input.addEventListener('keydown', (e) => this.handleKeyDown(e, fieldId));
|
52 |
+
input.addEventListener('blur', (e) => this.handleInputBlur(e, fieldId));
|
53 |
+
input.addEventListener('focus', (e) => this.handleInputFocus(e, fieldId, apiType));
|
54 |
+
}
|
55 |
+
|
56 |
+
async handleInputChange(event, apiType) {
|
57 |
+
const input = event.target;
|
58 |
+
const query = input.value.trim();
|
59 |
+
const fieldId = input.id;
|
60 |
+
|
61 |
+
this.clearSearchTimeout(fieldId);
|
62 |
+
|
63 |
+
if (query.length < 2) {
|
64 |
+
this.hideDropdown(fieldId);
|
65 |
+
return;
|
66 |
+
}
|
67 |
+
|
68 |
+
this.showLoadingState(fieldId);
|
69 |
+
this.debounceSearch(fieldId, query, apiType);
|
70 |
+
}
|
71 |
+
|
72 |
+
clearSearchTimeout(fieldId) {
|
73 |
+
if (this.searchTimeouts[fieldId]) {
|
74 |
+
clearTimeout(this.searchTimeouts[fieldId]);
|
75 |
+
}
|
76 |
+
}
|
77 |
+
|
78 |
+
debounceSearch(fieldId, query, apiType) {
|
79 |
+
this.searchTimeouts[fieldId] = setTimeout(async () => {
|
80 |
+
try {
|
81 |
+
const suggestions = await this.fetchSuggestions(query, apiType, fieldId);
|
82 |
+
this.showSuggestions(fieldId, suggestions, query);
|
83 |
+
} catch (error) {
|
84 |
+
console.error('Error fetching suggestions:', error);
|
85 |
+
this.hideDropdown(fieldId);
|
86 |
+
}
|
87 |
+
}, 300);
|
88 |
+
}
|
89 |
+
|
90 |
+
async fetchSuggestions(query, apiType, fieldId) {
|
91 |
+
if (apiType === 'tree-codes') {
|
92 |
+
return this.filterTreeCodes(query);
|
93 |
+
} else {
|
94 |
+
return this.searchTreeSuggestions(query, fieldId);
|
95 |
+
}
|
96 |
+
}
|
97 |
+
|
98 |
+
filterTreeCodes(query) {
|
99 |
+
return this.availableTreeCodes
|
100 |
+
.filter(code => code.toLowerCase().includes(query.toLowerCase()))
|
101 |
+
.slice(0, 10)
|
102 |
+
.map(code => ({
|
103 |
+
primary: code,
|
104 |
+
secondary: 'Tree Reference Code',
|
105 |
+
type: 'code'
|
106 |
+
}));
|
107 |
+
}
|
108 |
+
|
109 |
+
async searchTreeSuggestions(query, fieldId) {
|
110 |
+
const suggestions = await this.apiClient.searchTreeSuggestions(query, 10);
|
111 |
+
return suggestions.map(suggestion => ({
|
112 |
+
primary: this.getPrimaryText(suggestion, fieldId),
|
113 |
+
secondary: this.getSecondaryText(suggestion, fieldId),
|
114 |
+
badges: this.getBadges(suggestion),
|
115 |
+
data: suggestion
|
116 |
+
}));
|
117 |
+
}
|
118 |
+
|
119 |
+
getPrimaryText(suggestion, fieldId) {
|
120 |
+
const fieldMap = {
|
121 |
+
'localName': suggestion.local_name,
|
122 |
+
'scientificName': suggestion.scientific_name,
|
123 |
+
'commonName': suggestion.common_name
|
124 |
+
};
|
125 |
+
|
126 |
+
return fieldMap[fieldId] ||
|
127 |
+
suggestion.local_name ||
|
128 |
+
suggestion.scientific_name ||
|
129 |
+
suggestion.common_name;
|
130 |
+
}
|
131 |
+
|
132 |
+
getSecondaryText(suggestion, fieldId) {
|
133 |
+
const parts = [];
|
134 |
+
|
135 |
+
if (fieldId !== 'localName' && suggestion.local_name) {
|
136 |
+
parts.push(`Local: ${suggestion.local_name}`);
|
137 |
+
}
|
138 |
+
if (fieldId !== 'scientificName' && suggestion.scientific_name) {
|
139 |
+
parts.push(`Scientific: ${suggestion.scientific_name}`);
|
140 |
+
}
|
141 |
+
if (fieldId !== 'commonName' && suggestion.common_name) {
|
142 |
+
parts.push(`Common: ${suggestion.common_name}`);
|
143 |
+
}
|
144 |
+
if (suggestion.tree_code) {
|
145 |
+
parts.push(`Code: ${suggestion.tree_code}`);
|
146 |
+
}
|
147 |
+
|
148 |
+
return parts.join(' β’ ');
|
149 |
+
}
|
150 |
+
|
151 |
+
getBadges(suggestion) {
|
152 |
+
const badges = [];
|
153 |
+
if (suggestion.tree_code) {
|
154 |
+
badges.push(suggestion.tree_code);
|
155 |
+
}
|
156 |
+
if (suggestion.fruiting_season) {
|
157 |
+
badges.push(`Season: ${suggestion.fruiting_season}`);
|
158 |
+
}
|
159 |
+
return badges;
|
160 |
+
}
|
161 |
+
|
162 |
+
showLoadingState(fieldId) {
|
163 |
+
const dropdown = document.getElementById(`${fieldId}-dropdown`);
|
164 |
+
if (!dropdown) return;
|
165 |
+
|
166 |
+
dropdown.innerHTML = '<div class="autocomplete-loading">Searching...</div>';
|
167 |
+
dropdown.style.display = 'block';
|
168 |
+
this.activeDropdowns.add(fieldId);
|
169 |
+
}
|
170 |
+
|
171 |
+
showSuggestions(fieldId, suggestions, query) {
|
172 |
+
const dropdown = document.getElementById(`${fieldId}-dropdown`);
|
173 |
+
if (!dropdown) return;
|
174 |
+
|
175 |
+
if (suggestions.length === 0) {
|
176 |
+
dropdown.innerHTML = '<div class="autocomplete-no-results">No matching suggestions found</div>';
|
177 |
+
dropdown.style.display = 'block';
|
178 |
+
this.activeDropdowns.add(fieldId);
|
179 |
+
return;
|
180 |
+
}
|
181 |
+
|
182 |
+
const html = this.buildSuggestionsHTML(suggestions, query, fieldId);
|
183 |
+
dropdown.innerHTML = html;
|
184 |
+
dropdown.style.display = 'block';
|
185 |
+
this.activeDropdowns.add(fieldId);
|
186 |
+
this.selectedIndex = -1;
|
187 |
+
|
188 |
+
this.attachSuggestionClickHandlers(dropdown, suggestions);
|
189 |
+
}
|
190 |
+
|
191 |
+
buildSuggestionsHTML(suggestions, query, fieldId) {
|
192 |
+
return suggestions.map((suggestion, index) => `
|
193 |
+
<div class="autocomplete-item" data-index="${index}" data-field="${fieldId}">
|
194 |
+
<div class="autocomplete-primary">${this.highlightMatch(suggestion.primary, query)}</div>
|
195 |
+
${suggestion.secondary ? `<div class="autocomplete-secondary">${suggestion.secondary}</div>` : ''}
|
196 |
+
${this.buildBadgesHTML(suggestion.badges)}
|
197 |
+
</div>
|
198 |
+
`).join('');
|
199 |
+
}
|
200 |
+
|
201 |
+
buildBadgesHTML(badges) {
|
202 |
+
if (!badges || badges.length === 0) return '';
|
203 |
+
|
204 |
+
const badgeElements = badges.map(badge => `<span class="autocomplete-badge">${badge}</span>`).join('');
|
205 |
+
return `<div>${badgeElements}</div>`;
|
206 |
+
}
|
207 |
+
|
208 |
+
highlightMatch(text, query) {
|
209 |
+
if (!query || !text) return text;
|
210 |
+
|
211 |
+
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')})`, 'gi');
|
212 |
+
return text.replace(regex, '<strong>$1</strong>');
|
213 |
+
}
|
214 |
+
|
215 |
+
attachSuggestionClickHandlers(dropdown, suggestions) {
|
216 |
+
dropdown.querySelectorAll('.autocomplete-item').forEach(item => {
|
217 |
+
item.addEventListener('mousedown', (e) => this.handleSuggestionClick(e, suggestions));
|
218 |
+
});
|
219 |
+
}
|
220 |
+
|
221 |
+
handleSuggestionClick(event, suggestions) {
|
222 |
+
event.preventDefault();
|
223 |
+
const item = event.target.closest('.autocomplete-item');
|
224 |
+
const index = parseInt(item.dataset.index);
|
225 |
+
const fieldId = item.dataset.field;
|
226 |
+
const suggestion = suggestions[index];
|
227 |
+
|
228 |
+
this.applySuggestion(fieldId, suggestion);
|
229 |
+
this.hideDropdown(fieldId);
|
230 |
+
}
|
231 |
+
|
232 |
+
applySuggestion(fieldId, suggestion) {
|
233 |
+
const input = document.getElementById(fieldId);
|
234 |
+
|
235 |
+
if (suggestion.type === 'code') {
|
236 |
+
input.value = suggestion.primary;
|
237 |
+
} else {
|
238 |
+
this.applySuggestionData(input, fieldId, suggestion);
|
239 |
+
this.autoFillRelatedFields(suggestion.data, fieldId);
|
240 |
+
}
|
241 |
+
|
242 |
+
input.dispatchEvent(new Event('input', { bubbles: true }));
|
243 |
+
}
|
244 |
+
|
245 |
+
applySuggestionData(input, fieldId, suggestion) {
|
246 |
+
const data = suggestion.data;
|
247 |
+
const fieldValueMap = {
|
248 |
+
'localName': data.local_name,
|
249 |
+
'scientificName': data.scientific_name,
|
250 |
+
'commonName': data.common_name
|
251 |
+
};
|
252 |
+
|
253 |
+
input.value = fieldValueMap[fieldId] || suggestion.primary;
|
254 |
+
}
|
255 |
+
|
256 |
+
autoFillRelatedFields(data, excludeFieldId) {
|
257 |
+
const fields = {
|
258 |
+
'localName': data.local_name,
|
259 |
+
'scientificName': data.scientific_name,
|
260 |
+
'commonName': data.common_name,
|
261 |
+
'treeCode': data.tree_code
|
262 |
+
};
|
263 |
+
|
264 |
+
Object.entries(fields).forEach(([fieldId, value]) => {
|
265 |
+
if (fieldId !== excludeFieldId && value) {
|
266 |
+
this.fillEmptyField(fieldId, value);
|
267 |
+
}
|
268 |
+
});
|
269 |
+
}
|
270 |
+
|
271 |
+
fillEmptyField(fieldId, value) {
|
272 |
+
const input = document.getElementById(fieldId);
|
273 |
+
if (input && !input.value.trim()) {
|
274 |
+
input.value = value;
|
275 |
+
this.uiManager.highlightAutoFilledField(fieldId);
|
276 |
+
}
|
277 |
+
}
|
278 |
+
|
279 |
+
handleKeyDown(event, fieldId) {
|
280 |
+
const dropdown = document.getElementById(`${fieldId}-dropdown`);
|
281 |
+
if (!dropdown || dropdown.style.display === 'none') return;
|
282 |
+
|
283 |
+
const items = dropdown.querySelectorAll('.autocomplete-item');
|
284 |
+
if (items.length === 0) return;
|
285 |
+
|
286 |
+
switch (event.key) {
|
287 |
+
case 'ArrowDown':
|
288 |
+
event.preventDefault();
|
289 |
+
this.selectedIndex = Math.min(this.selectedIndex + 1, items.length - 1);
|
290 |
+
this.updateHighlight(items);
|
291 |
+
break;
|
292 |
+
|
293 |
+
case 'ArrowUp':
|
294 |
+
event.preventDefault();
|
295 |
+
this.selectedIndex = Math.max(this.selectedIndex - 1, -1);
|
296 |
+
this.updateHighlight(items);
|
297 |
+
break;
|
298 |
+
|
299 |
+
case 'Enter':
|
300 |
+
event.preventDefault();
|
301 |
+
if (this.selectedIndex >= 0 && items[this.selectedIndex]) {
|
302 |
+
items[this.selectedIndex].click();
|
303 |
+
}
|
304 |
+
break;
|
305 |
+
|
306 |
+
case 'Escape':
|
307 |
+
event.preventDefault();
|
308 |
+
this.hideDropdown(fieldId);
|
309 |
+
break;
|
310 |
+
}
|
311 |
+
}
|
312 |
+
|
313 |
+
updateHighlight(items) {
|
314 |
+
items.forEach((item, index) => {
|
315 |
+
item.classList.toggle('highlighted', index === this.selectedIndex);
|
316 |
+
});
|
317 |
+
}
|
318 |
+
|
319 |
+
handleInputBlur(event, fieldId) {
|
320 |
+
setTimeout(() => this.hideDropdown(fieldId), 150);
|
321 |
+
}
|
322 |
+
|
323 |
+
handleInputFocus(event, fieldId, apiType) {
|
324 |
+
const input = event.target;
|
325 |
+
if (input.value.length >= 2) {
|
326 |
+
this.handleInputChange(event, apiType);
|
327 |
+
}
|
328 |
+
}
|
329 |
+
|
330 |
+
hideDropdown(fieldId) {
|
331 |
+
const dropdown = document.getElementById(`${fieldId}-dropdown`);
|
332 |
+
if (dropdown) {
|
333 |
+
dropdown.style.display = 'none';
|
334 |
+
dropdown.innerHTML = '';
|
335 |
+
this.activeDropdowns.delete(fieldId);
|
336 |
+
this.selectedIndex = -1;
|
337 |
+
}
|
338 |
+
}
|
339 |
+
|
340 |
+
hideAllDropdowns() {
|
341 |
+
this.activeDropdowns.forEach(fieldId => {
|
342 |
+
this.hideDropdown(fieldId);
|
343 |
+
});
|
344 |
+
}
|
345 |
+
}
|
@@ -0,0 +1,301 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Form Manager Module
|
3 |
+
* Handles form operations, validation, and data management
|
4 |
+
*/
|
5 |
+
export class FormManager {
|
6 |
+
constructor(apiClient, uiManager) {
|
7 |
+
this.apiClient = apiClient;
|
8 |
+
this.uiManager = uiManager;
|
9 |
+
this.uploadedPhotos = {};
|
10 |
+
this.audioFile = null;
|
11 |
+
this.currentEditId = null;
|
12 |
+
}
|
13 |
+
|
14 |
+
async initialize() {
|
15 |
+
try {
|
16 |
+
const formOptions = await this.apiClient.loadFormOptions();
|
17 |
+
this.renderMultiSelect('utilityOptions', formOptions.utilities);
|
18 |
+
this.renderMultiSelect('phenologyOptions', formOptions.phenologyStages);
|
19 |
+
this.renderPhotoCategories(formOptions.photoCategories);
|
20 |
+
} catch (error) {
|
21 |
+
console.error('Error initializing form:', error);
|
22 |
+
throw error;
|
23 |
+
}
|
24 |
+
}
|
25 |
+
|
26 |
+
renderMultiSelect(containerId, options) {
|
27 |
+
const container = document.getElementById(containerId);
|
28 |
+
if (!container) return;
|
29 |
+
|
30 |
+
container.innerHTML = '';
|
31 |
+
|
32 |
+
options.forEach(option => {
|
33 |
+
const label = document.createElement('label');
|
34 |
+
label.innerHTML = `
|
35 |
+
<input type="checkbox" value="${option}"> ${option}
|
36 |
+
`;
|
37 |
+
container.appendChild(label);
|
38 |
+
});
|
39 |
+
}
|
40 |
+
|
41 |
+
renderPhotoCategories(categories) {
|
42 |
+
const container = document.getElementById('photoCategories');
|
43 |
+
if (!container) return;
|
44 |
+
|
45 |
+
container.innerHTML = '';
|
46 |
+
|
47 |
+
categories.forEach(category => {
|
48 |
+
const categoryDiv = document.createElement('div');
|
49 |
+
categoryDiv.className = 'photo-category';
|
50 |
+
categoryDiv.innerHTML = `
|
51 |
+
<div class="photo-category-header">
|
52 |
+
<div class="photo-category-title">
|
53 |
+
<div class="photo-category-icon">IMG</div>
|
54 |
+
${category}
|
55 |
+
</div>
|
56 |
+
</div>
|
57 |
+
<div class="photo-upload-area">
|
58 |
+
<div class="photo-upload" data-category="${category}">
|
59 |
+
<div class="photo-upload-icon">+</div>
|
60 |
+
<div>Click to select ${category} photo</div>
|
61 |
+
</div>
|
62 |
+
<button type="button" class="camera-btn" data-category="${category}">
|
63 |
+
Camera
|
64 |
+
</button>
|
65 |
+
</div>
|
66 |
+
<div class="uploaded-file" id="photo-${category}" style="display: none;"></div>
|
67 |
+
`;
|
68 |
+
container.appendChild(categoryDiv);
|
69 |
+
});
|
70 |
+
}
|
71 |
+
|
72 |
+
getSelectedValues(containerId) {
|
73 |
+
const container = document.getElementById(containerId);
|
74 |
+
if (!container) return [];
|
75 |
+
|
76 |
+
const checkboxes = container.querySelectorAll('input[type="checkbox"]:checked');
|
77 |
+
return Array.from(checkboxes).map(cb => cb.value);
|
78 |
+
}
|
79 |
+
|
80 |
+
getFormData() {
|
81 |
+
const utilityValues = this.getSelectedValues('utilityOptions');
|
82 |
+
const phenologyValues = this.getSelectedValues('phenologyOptions');
|
83 |
+
|
84 |
+
return {
|
85 |
+
latitude: this.getNumericValue('latitude'),
|
86 |
+
longitude: this.getNumericValue('longitude'),
|
87 |
+
local_name: this.getStringValue('localName'),
|
88 |
+
scientific_name: this.getStringValue('scientificName'),
|
89 |
+
common_name: this.getStringValue('commonName'),
|
90 |
+
tree_code: this.getStringValue('treeCode'),
|
91 |
+
height: this.getNumericValue('height'),
|
92 |
+
width: this.getNumericValue('width'),
|
93 |
+
utility: utilityValues.length > 0 ? utilityValues : [],
|
94 |
+
phenology_stages: phenologyValues.length > 0 ? phenologyValues : [],
|
95 |
+
storytelling_text: this.getStringValue('storytellingText'),
|
96 |
+
storytelling_audio: this.audioFile,
|
97 |
+
photographs: Object.keys(this.uploadedPhotos).length > 0 ? this.uploadedPhotos : null,
|
98 |
+
notes: this.getStringValue('notes')
|
99 |
+
};
|
100 |
+
}
|
101 |
+
|
102 |
+
getNumericValue(fieldId) {
|
103 |
+
const element = document.getElementById(fieldId);
|
104 |
+
if (!element || !element.value) return null;
|
105 |
+
const value = parseFloat(element.value);
|
106 |
+
return isNaN(value) ? null : value;
|
107 |
+
}
|
108 |
+
|
109 |
+
getStringValue(fieldId) {
|
110 |
+
const element = document.getElementById(fieldId);
|
111 |
+
return element && element.value ? element.value : null;
|
112 |
+
}
|
113 |
+
|
114 |
+
populateForm(treeData) {
|
115 |
+
this.setFieldValue('latitude', treeData.latitude);
|
116 |
+
this.setFieldValue('longitude', treeData.longitude);
|
117 |
+
this.setFieldValue('localName', treeData.local_name || '');
|
118 |
+
this.setFieldValue('scientificName', treeData.scientific_name || '');
|
119 |
+
this.setFieldValue('commonName', treeData.common_name || '');
|
120 |
+
this.setFieldValue('treeCode', treeData.tree_code || '');
|
121 |
+
this.setFieldValue('height', treeData.height || '');
|
122 |
+
this.setFieldValue('width', treeData.width || '');
|
123 |
+
this.setFieldValue('storytellingText', treeData.storytelling_text || '');
|
124 |
+
this.setFieldValue('notes', treeData.notes || '');
|
125 |
+
|
126 |
+
// Handle utility checkboxes
|
127 |
+
if (treeData.utility && Array.isArray(treeData.utility)) {
|
128 |
+
this.setCheckboxValues('utilityOptions', treeData.utility);
|
129 |
+
}
|
130 |
+
|
131 |
+
// Handle phenology checkboxes
|
132 |
+
if (treeData.phenology_stages && Array.isArray(treeData.phenology_stages)) {
|
133 |
+
this.setCheckboxValues('phenologyOptions', treeData.phenology_stages);
|
134 |
+
}
|
135 |
+
}
|
136 |
+
|
137 |
+
setFieldValue(fieldId, value) {
|
138 |
+
const element = document.getElementById(fieldId);
|
139 |
+
if (element) {
|
140 |
+
element.value = value;
|
141 |
+
}
|
142 |
+
}
|
143 |
+
|
144 |
+
setCheckboxValues(containerId, values) {
|
145 |
+
const container = document.getElementById(containerId);
|
146 |
+
if (!container) return;
|
147 |
+
|
148 |
+
container.querySelectorAll('input[type="checkbox"]').forEach(checkbox => {
|
149 |
+
checkbox.checked = values.includes(checkbox.value);
|
150 |
+
});
|
151 |
+
}
|
152 |
+
|
153 |
+
resetForm(silent = false) {
|
154 |
+
const form = document.getElementById('treeForm');
|
155 |
+
if (form) {
|
156 |
+
form.reset();
|
157 |
+
}
|
158 |
+
|
159 |
+
this.uploadedPhotos = {};
|
160 |
+
this.audioFile = null;
|
161 |
+
this.currentEditId = null;
|
162 |
+
|
163 |
+
// Clear uploaded file indicators
|
164 |
+
document.querySelectorAll('.uploaded-file').forEach(el => {
|
165 |
+
el.style.display = 'none';
|
166 |
+
el.innerHTML = '';
|
167 |
+
});
|
168 |
+
|
169 |
+
// Reset audio controls
|
170 |
+
this.resetAudioControls();
|
171 |
+
|
172 |
+
if (!silent) {
|
173 |
+
this.uiManager.showMessage('Form has been reset.', 'success');
|
174 |
+
}
|
175 |
+
}
|
176 |
+
|
177 |
+
resetAudioControls() {
|
178 |
+
const audioElement = document.getElementById('audioPlayback');
|
179 |
+
if (audioElement) {
|
180 |
+
audioElement.classList.add('hidden');
|
181 |
+
audioElement.src = '';
|
182 |
+
}
|
183 |
+
|
184 |
+
const recordingStatus = document.getElementById('recordingStatus');
|
185 |
+
if (recordingStatus) {
|
186 |
+
recordingStatus.textContent = 'Click to start recording';
|
187 |
+
}
|
188 |
+
|
189 |
+
const audioUploadResult = document.getElementById('audioUploadResult');
|
190 |
+
if (audioUploadResult) {
|
191 |
+
audioUploadResult.innerHTML = '';
|
192 |
+
}
|
193 |
+
}
|
194 |
+
|
195 |
+
async handleFileUpload(file, type, category = null) {
|
196 |
+
try {
|
197 |
+
const result = await this.apiClient.uploadFile(file, type, category);
|
198 |
+
|
199 |
+
if (type === 'image' && category) {
|
200 |
+
this.uploadedPhotos[category] = result.filename;
|
201 |
+
this.showUploadSuccess(category, file.name, 'photo');
|
202 |
+
} else if (type === 'audio') {
|
203 |
+
this.audioFile = result.filename;
|
204 |
+
this.showUploadSuccess(null, file.name, 'audio');
|
205 |
+
}
|
206 |
+
|
207 |
+
return result;
|
208 |
+
} catch (error) {
|
209 |
+
console.error(`Error uploading ${type}:`, error);
|
210 |
+
this.uiManager.showMessage(`Error uploading ${type}: ${error.message}`, 'error');
|
211 |
+
throw error;
|
212 |
+
}
|
213 |
+
}
|
214 |
+
|
215 |
+
showUploadSuccess(category, filename, type) {
|
216 |
+
if (type === 'photo' && category) {
|
217 |
+
const resultDiv = document.getElementById(`photo-${category}`);
|
218 |
+
if (resultDiv) {
|
219 |
+
resultDiv.style.display = 'block';
|
220 |
+
resultDiv.innerHTML = `${filename} uploaded successfully`;
|
221 |
+
}
|
222 |
+
} else if (type === 'audio') {
|
223 |
+
const resultDiv = document.getElementById('audioUploadResult');
|
224 |
+
if (resultDiv) {
|
225 |
+
resultDiv.innerHTML = `<div class="uploaded-file">${filename} uploaded successfully</div>`;
|
226 |
+
}
|
227 |
+
}
|
228 |
+
}
|
229 |
+
|
230 |
+
setEditMode(treeId) {
|
231 |
+
this.currentEditId = treeId;
|
232 |
+
|
233 |
+
// Update submit button
|
234 |
+
const submitBtn = document.querySelector('button[type="submit"]');
|
235 |
+
if (submitBtn) {
|
236 |
+
submitBtn.textContent = 'Update Tree Record';
|
237 |
+
}
|
238 |
+
|
239 |
+
// Add cancel edit button if it doesn't exist
|
240 |
+
this.addCancelButton();
|
241 |
+
}
|
242 |
+
|
243 |
+
addCancelButton() {
|
244 |
+
if (document.getElementById('cancelEdit')) return;
|
245 |
+
|
246 |
+
const cancelBtn = document.createElement('button');
|
247 |
+
cancelBtn.type = 'button';
|
248 |
+
cancelBtn.id = 'cancelEdit';
|
249 |
+
cancelBtn.className = 'btn btn-outline';
|
250 |
+
cancelBtn.textContent = 'Cancel Edit';
|
251 |
+
|
252 |
+
const formActions = document.querySelector('.form-actions');
|
253 |
+
const submitBtn = document.querySelector('button[type="submit"]');
|
254 |
+
if (formActions && submitBtn) {
|
255 |
+
formActions.insertBefore(cancelBtn, submitBtn);
|
256 |
+
}
|
257 |
+
}
|
258 |
+
|
259 |
+
exitEditMode() {
|
260 |
+
this.currentEditId = null;
|
261 |
+
|
262 |
+
// Restore original submit button text
|
263 |
+
const submitBtn = document.querySelector('button[type="submit"]');
|
264 |
+
if (submitBtn) {
|
265 |
+
submitBtn.textContent = 'Save Tree Record';
|
266 |
+
}
|
267 |
+
|
268 |
+
// Remove cancel button
|
269 |
+
const cancelBtn = document.getElementById('cancelEdit');
|
270 |
+
if (cancelBtn) {
|
271 |
+
cancelBtn.remove();
|
272 |
+
}
|
273 |
+
}
|
274 |
+
|
275 |
+
isInEditMode() {
|
276 |
+
return this.currentEditId !== null;
|
277 |
+
}
|
278 |
+
|
279 |
+
getCurrentEditId() {
|
280 |
+
return this.currentEditId;
|
281 |
+
}
|
282 |
+
|
283 |
+
validateForm() {
|
284 |
+
const latitude = this.getNumericValue('latitude');
|
285 |
+
const longitude = this.getNumericValue('longitude');
|
286 |
+
|
287 |
+
if (latitude === null || longitude === null) {
|
288 |
+
throw new Error('Latitude and longitude are required');
|
289 |
+
}
|
290 |
+
|
291 |
+
if (latitude < -90 || latitude > 90) {
|
292 |
+
throw new Error('Latitude must be between -90 and 90 degrees');
|
293 |
+
}
|
294 |
+
|
295 |
+
if (longitude < -180 || longitude > 180) {
|
296 |
+
throw new Error('Longitude must be between -180 and 180 degrees');
|
297 |
+
}
|
298 |
+
|
299 |
+
return true;
|
300 |
+
}
|
301 |
+
}
|
@@ -0,0 +1,298 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* Media Manager Module
|
3 |
+
* Handles audio recording, photo capture, and file uploads
|
4 |
+
*/
|
5 |
+
export class MediaManager {
|
6 |
+
constructor(formManager, uiManager) {
|
7 |
+
this.formManager = formManager;
|
8 |
+
this.uiManager = uiManager;
|
9 |
+
this.mediaRecorder = null;
|
10 |
+
this.audioChunks = [];
|
11 |
+
this.isRecording = false;
|
12 |
+
}
|
13 |
+
|
14 |
+
initialize() {
|
15 |
+
this.setupDragAndDrop();
|
16 |
+
this.attachEventListeners();
|
17 |
+
}
|
18 |
+
|
19 |
+
attachEventListeners() {
|
20 |
+
// Photo upload handlers
|
21 |
+
this.setupPhotoUploadHandlers();
|
22 |
+
|
23 |
+
// Audio upload handler
|
24 |
+
const audioUpload = document.getElementById('audioUpload');
|
25 |
+
if (audioUpload) {
|
26 |
+
audioUpload.addEventListener('click', () => this.selectAudioFile());
|
27 |
+
}
|
28 |
+
|
29 |
+
// Recording button (if exists)
|
30 |
+
const recordBtn = document.getElementById('recordBtn');
|
31 |
+
if (recordBtn) {
|
32 |
+
recordBtn.addEventListener('click', () => this.toggleRecording());
|
33 |
+
}
|
34 |
+
}
|
35 |
+
|
36 |
+
setupPhotoUploadHandlers() {
|
37 |
+
document.addEventListener('click', (e) => {
|
38 |
+
if (e.target.matches('.photo-upload, .photo-upload *')) {
|
39 |
+
const uploadArea = e.target.closest('.photo-upload');
|
40 |
+
const category = uploadArea?.dataset.category;
|
41 |
+
if (category) {
|
42 |
+
this.selectPhotoFile(category);
|
43 |
+
}
|
44 |
+
}
|
45 |
+
|
46 |
+
if (e.target.matches('.camera-btn')) {
|
47 |
+
const category = e.target.dataset.category;
|
48 |
+
if (category) {
|
49 |
+
this.capturePhoto(category);
|
50 |
+
}
|
51 |
+
}
|
52 |
+
});
|
53 |
+
}
|
54 |
+
|
55 |
+
setupDragAndDrop() {
|
56 |
+
const audioUpload = document.getElementById('audioUpload');
|
57 |
+
if (!audioUpload) return;
|
58 |
+
|
59 |
+
audioUpload.addEventListener('dragover', (e) => {
|
60 |
+
e.preventDefault();
|
61 |
+
audioUpload.classList.add('dragover');
|
62 |
+
});
|
63 |
+
|
64 |
+
audioUpload.addEventListener('dragleave', () => {
|
65 |
+
audioUpload.classList.remove('dragover');
|
66 |
+
});
|
67 |
+
|
68 |
+
audioUpload.addEventListener('drop', (e) => {
|
69 |
+
e.preventDefault();
|
70 |
+
audioUpload.classList.remove('dragover');
|
71 |
+
|
72 |
+
const files = e.dataTransfer.files;
|
73 |
+
if (files.length > 0 && files[0].type.startsWith('audio/')) {
|
74 |
+
this.handleAudioFile(files[0]);
|
75 |
+
}
|
76 |
+
});
|
77 |
+
}
|
78 |
+
|
79 |
+
async selectPhotoFile(category) {
|
80 |
+
const input = this.uiManager.createFileInput('image/*', true);
|
81 |
+
|
82 |
+
input.onchange = (e) => {
|
83 |
+
const file = e.target.files[0];
|
84 |
+
if (file) {
|
85 |
+
this.handlePhotoFile(file, category);
|
86 |
+
}
|
87 |
+
};
|
88 |
+
|
89 |
+
input.click();
|
90 |
+
}
|
91 |
+
|
92 |
+
async capturePhoto(category) {
|
93 |
+
const input = this.uiManager.createFileInput('image/*', true);
|
94 |
+
|
95 |
+
input.onchange = (e) => {
|
96 |
+
const file = e.target.files[0];
|
97 |
+
if (file) {
|
98 |
+
this.handlePhotoFile(file, category);
|
99 |
+
}
|
100 |
+
};
|
101 |
+
|
102 |
+
input.click();
|
103 |
+
}
|
104 |
+
|
105 |
+
async selectAudioFile() {
|
106 |
+
const input = this.uiManager.createFileInput('audio/*');
|
107 |
+
|
108 |
+
input.onchange = (e) => {
|
109 |
+
const file = e.target.files[0];
|
110 |
+
if (file) {
|
111 |
+
this.handleAudioFile(file);
|
112 |
+
}
|
113 |
+
};
|
114 |
+
|
115 |
+
input.click();
|
116 |
+
}
|
117 |
+
|
118 |
+
async handlePhotoFile(file, category) {
|
119 |
+
try {
|
120 |
+
this.validateImageFile(file);
|
121 |
+
await this.formManager.handleFileUpload(file, 'image', category);
|
122 |
+
} catch (error) {
|
123 |
+
this.uiManager.showMessage(`Error with photo file: ${error.message}`, 'error');
|
124 |
+
}
|
125 |
+
}
|
126 |
+
|
127 |
+
async handleAudioFile(file) {
|
128 |
+
try {
|
129 |
+
this.validateAudioFile(file);
|
130 |
+
await this.formManager.handleFileUpload(file, 'audio');
|
131 |
+
} catch (error) {
|
132 |
+
this.uiManager.showMessage(`Error with audio file: ${error.message}`, 'error');
|
133 |
+
}
|
134 |
+
}
|
135 |
+
|
136 |
+
validateImageFile(file) {
|
137 |
+
const maxSize = 10 * 1024 * 1024; // 10MB
|
138 |
+
const allowedTypes = ['image/jpeg', 'image/png', 'image/gif', 'image/webp'];
|
139 |
+
|
140 |
+
if (file.size > maxSize) {
|
141 |
+
throw new Error('Image file size must be less than 10MB');
|
142 |
+
}
|
143 |
+
|
144 |
+
if (!allowedTypes.includes(file.type)) {
|
145 |
+
throw new Error('Only JPEG, PNG, GIF, and WebP images are allowed');
|
146 |
+
}
|
147 |
+
}
|
148 |
+
|
149 |
+
validateAudioFile(file) {
|
150 |
+
const maxSize = 50 * 1024 * 1024; // 50MB
|
151 |
+
const allowedTypes = ['audio/mpeg', 'audio/wav', 'audio/mp4', 'audio/aac', 'audio/ogg'];
|
152 |
+
|
153 |
+
if (file.size > maxSize) {
|
154 |
+
throw new Error('Audio file size must be less than 50MB');
|
155 |
+
}
|
156 |
+
|
157 |
+
if (!allowedTypes.includes(file.type)) {
|
158 |
+
throw new Error('Only MP3, WAV, M4A, AAC, and OGG audio files are allowed');
|
159 |
+
}
|
160 |
+
}
|
161 |
+
|
162 |
+
async toggleRecording() {
|
163 |
+
if (!this.isRecording) {
|
164 |
+
await this.startRecording();
|
165 |
+
} else {
|
166 |
+
this.stopRecording();
|
167 |
+
}
|
168 |
+
}
|
169 |
+
|
170 |
+
async startRecording() {
|
171 |
+
try {
|
172 |
+
const stream = await navigator.mediaDevices.getUserMedia({ audio: true });
|
173 |
+
this.mediaRecorder = new MediaRecorder(stream);
|
174 |
+
this.audioChunks = [];
|
175 |
+
|
176 |
+
this.mediaRecorder.ondataavailable = (event) => {
|
177 |
+
this.audioChunks.push(event.data);
|
178 |
+
};
|
179 |
+
|
180 |
+
this.mediaRecorder.onstop = async () => {
|
181 |
+
const audioBlob = new Blob(this.audioChunks, { type: 'audio/wav' });
|
182 |
+
const audioFile = new File([audioBlob], 'recording.wav', { type: 'audio/wav' });
|
183 |
+
await this.handleAudioFile(audioFile);
|
184 |
+
|
185 |
+
this.showAudioPlayback(audioBlob);
|
186 |
+
};
|
187 |
+
|
188 |
+
this.mediaRecorder.start();
|
189 |
+
this.isRecording = true;
|
190 |
+
this.updateRecordingUI(true);
|
191 |
+
|
192 |
+
} catch (error) {
|
193 |
+
console.error('Error starting recording:', error);
|
194 |
+
this.uiManager.showMessage('Error accessing microphone: ' + error.message, 'error');
|
195 |
+
}
|
196 |
+
}
|
197 |
+
|
198 |
+
stopRecording() {
|
199 |
+
if (this.mediaRecorder && this.isRecording) {
|
200 |
+
this.mediaRecorder.stop();
|
201 |
+
this.mediaRecorder.stream.getTracks().forEach(track => track.stop());
|
202 |
+
this.isRecording = false;
|
203 |
+
this.updateRecordingUI(false);
|
204 |
+
}
|
205 |
+
}
|
206 |
+
|
207 |
+
updateRecordingUI(isRecording) {
|
208 |
+
const recordBtn = document.getElementById('recordBtn');
|
209 |
+
const status = document.getElementById('recordingStatus');
|
210 |
+
|
211 |
+
if (recordBtn) {
|
212 |
+
recordBtn.classList.toggle('recording', isRecording);
|
213 |
+
recordBtn.innerHTML = isRecording ? 'Stop' : 'Record';
|
214 |
+
}
|
215 |
+
|
216 |
+
if (status) {
|
217 |
+
status.textContent = isRecording
|
218 |
+
? 'Recording... Click to stop'
|
219 |
+
: 'Recording saved!';
|
220 |
+
}
|
221 |
+
}
|
222 |
+
|
223 |
+
showAudioPlayback(audioBlob) {
|
224 |
+
const audioElement = document.getElementById('audioPlayback');
|
225 |
+
if (audioElement) {
|
226 |
+
audioElement.src = URL.createObjectURL(audioBlob);
|
227 |
+
audioElement.classList.remove('hidden');
|
228 |
+
}
|
229 |
+
}
|
230 |
+
|
231 |
+
getCurrentLocation() {
|
232 |
+
if (!navigator.geolocation) {
|
233 |
+
this.uiManager.showMessage('Geolocation is not supported by this browser.', 'error');
|
234 |
+
return;
|
235 |
+
}
|
236 |
+
|
237 |
+
this.uiManager.updateLocationButtonState(true);
|
238 |
+
|
239 |
+
navigator.geolocation.getCurrentPosition(
|
240 |
+
(position) => {
|
241 |
+
const latitude = position.coords.latitude.toFixed(7);
|
242 |
+
const longitude = position.coords.longitude.toFixed(7);
|
243 |
+
|
244 |
+
this.setLocationFields(latitude, longitude);
|
245 |
+
this.uiManager.updateLocationButtonState(false);
|
246 |
+
this.uiManager.showMessage('Location retrieved successfully!', 'success');
|
247 |
+
},
|
248 |
+
(error) => {
|
249 |
+
this.uiManager.updateLocationButtonState(false);
|
250 |
+
this.uiManager.showMessage('Error getting location: ' + error.message, 'error');
|
251 |
+
},
|
252 |
+
{
|
253 |
+
enableHighAccuracy: true,
|
254 |
+
timeout: 10000,
|
255 |
+
maximumAge: 300000 // 5 minutes
|
256 |
+
}
|
257 |
+
);
|
258 |
+
}
|
259 |
+
|
260 |
+
setLocationFields(latitude, longitude) {
|
261 |
+
const latElement = document.getElementById('latitude');
|
262 |
+
const lngElement = document.getElementById('longitude');
|
263 |
+
|
264 |
+
if (latElement && lngElement) {
|
265 |
+
latElement.value = latitude;
|
266 |
+
lngElement.value = longitude;
|
267 |
+
}
|
268 |
+
}
|
269 |
+
|
270 |
+
// Helper methods for file handling
|
271 |
+
getFileExtension(filename) {
|
272 |
+
return filename.split('.').pop().toLowerCase();
|
273 |
+
}
|
274 |
+
|
275 |
+
formatFileSize(bytes) {
|
276 |
+
if (bytes === 0) return '0 Bytes';
|
277 |
+
|
278 |
+
const k = 1024;
|
279 |
+
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
|
280 |
+
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
281 |
+
|
282 |
+
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
|
283 |
+
}
|
284 |
+
|
285 |
+
isImageFile(file) {
|
286 |
+
return file.type.startsWith('image/');
|
287 |
+
}
|
288 |
+
|
289 |
+
isAudioFile(file) {
|
290 |
+
return file.type.startsWith('audio/');
|
291 |
+
}
|
292 |
+
|
293 |
+
createImagePreview(file, callback) {
|
294 |
+
const reader = new FileReader();
|
295 |
+
reader.onload = (e) => callback(e.target.result);
|
296 |
+
reader.readAsDataURL(file);
|
297 |
+
}
|
298 |
+
}
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* UI Manager Module
|
3 |
+
* Handles user interface updates, messages, and tree list display
|
4 |
+
*/
|
5 |
+
export class UIManager {
|
6 |
+
constructor(authManager) {
|
7 |
+
this.authManager = authManager;
|
8 |
+
}
|
9 |
+
|
10 |
+
initialize() {
|
11 |
+
this.displayUserInfo();
|
12 |
+
this.loadSelectedLocation();
|
13 |
+
}
|
14 |
+
|
15 |
+
displayUserInfo() {
|
16 |
+
if (!this.authManager.currentUser) return;
|
17 |
+
|
18 |
+
const userNameEl = document.getElementById('userName');
|
19 |
+
const userRoleEl = document.getElementById('userRole');
|
20 |
+
const userAvatarEl = document.getElementById('userAvatar');
|
21 |
+
|
22 |
+
if (userNameEl) {
|
23 |
+
userNameEl.textContent = this.authManager.currentUser.full_name;
|
24 |
+
}
|
25 |
+
|
26 |
+
if (userRoleEl) {
|
27 |
+
userRoleEl.textContent = this.authManager.currentUser.role;
|
28 |
+
}
|
29 |
+
|
30 |
+
if (userAvatarEl) {
|
31 |
+
userAvatarEl.textContent = this.authManager.currentUser.full_name.charAt(0).toUpperCase();
|
32 |
+
}
|
33 |
+
}
|
34 |
+
|
35 |
+
loadSelectedLocation() {
|
36 |
+
const selectedLocation = localStorage.getItem('selectedLocation');
|
37 |
+
if (selectedLocation) {
|
38 |
+
try {
|
39 |
+
const location = JSON.parse(selectedLocation);
|
40 |
+
const latElement = document.getElementById('latitude');
|
41 |
+
const lngElement = document.getElementById('longitude');
|
42 |
+
|
43 |
+
if (latElement && lngElement) {
|
44 |
+
latElement.value = location.lat.toFixed(6);
|
45 |
+
lngElement.value = location.lng.toFixed(6);
|
46 |
+
|
47 |
+
// Clear the stored location
|
48 |
+
localStorage.removeItem('selectedLocation');
|
49 |
+
|
50 |
+
this.showMessage('Location loaded from map!', 'success');
|
51 |
+
}
|
52 |
+
} catch (error) {
|
53 |
+
console.error('Error loading selected location:', error);
|
54 |
+
}
|
55 |
+
}
|
56 |
+
}
|
57 |
+
|
58 |
+
showMessage(message, type) {
|
59 |
+
const messageDiv = document.getElementById('message');
|
60 |
+
if (!messageDiv) return;
|
61 |
+
|
62 |
+
messageDiv.className = `message ${type === 'error' ? 'error' : 'success'}`;
|
63 |
+
messageDiv.textContent = message;
|
64 |
+
|
65 |
+
// Auto-hide after 5 seconds
|
66 |
+
setTimeout(() => {
|
67 |
+
messageDiv.textContent = '';
|
68 |
+
messageDiv.className = '';
|
69 |
+
}, 5000);
|
70 |
+
}
|
71 |
+
|
72 |
+
renderTreeList(trees) {
|
73 |
+
const treeList = document.getElementById('treeList');
|
74 |
+
if (!treeList) return;
|
75 |
+
|
76 |
+
if (trees.length === 0) {
|
77 |
+
treeList.innerHTML = '<div class="loading">No trees recorded yet</div>';
|
78 |
+
return;
|
79 |
+
}
|
80 |
+
|
81 |
+
treeList.innerHTML = trees.map(tree => {
|
82 |
+
const canEdit = this.authManager.canEditTree(tree.created_by);
|
83 |
+
const canDelete = this.authManager.canDeleteTree(tree.created_by);
|
84 |
+
|
85 |
+
return `
|
86 |
+
<div class="tree-item" data-tree-id="${tree.id}">
|
87 |
+
<div class="tree-header">
|
88 |
+
<div class="tree-id">Tree #${tree.id}</div>
|
89 |
+
<div class="tree-actions">
|
90 |
+
${canEdit ? `<button class="btn-icon edit-tree" data-tree-id="${tree.id}" title="Edit Tree">Edit</button>` : ''}
|
91 |
+
${canDelete ? `<button class="btn-icon delete-tree" data-tree-id="${tree.id}" title="Delete Tree">Delete</button>` : ''}
|
92 |
+
</div>
|
93 |
+
</div>
|
94 |
+
<div class="tree-info">
|
95 |
+
${tree.scientific_name || tree.common_name || tree.local_name || 'Unnamed'}
|
96 |
+
<br>${tree.latitude.toFixed(4)}, ${tree.longitude.toFixed(4)}
|
97 |
+
${tree.tree_code ? `<br>Code: ${tree.tree_code}` : ''}
|
98 |
+
<br>${new Date(tree.created_at).toLocaleDateString()}
|
99 |
+
<br>By: ${tree.created_by || 'Unknown'}
|
100 |
+
</div>
|
101 |
+
</div>
|
102 |
+
`;
|
103 |
+
}).join('');
|
104 |
+
}
|
105 |
+
|
106 |
+
showLoadingState(containerId, message = 'Loading...') {
|
107 |
+
const container = document.getElementById(containerId);
|
108 |
+
if (!container) return;
|
109 |
+
|
110 |
+
container.innerHTML = `
|
111 |
+
<div class="loading">
|
112 |
+
<div class="spinner"></div>
|
113 |
+
${message}
|
114 |
+
</div>
|
115 |
+
`;
|
116 |
+
}
|
117 |
+
|
118 |
+
showErrorState(containerId, message = 'Error loading content') {
|
119 |
+
const container = document.getElementById(containerId);
|
120 |
+
if (!container) return;
|
121 |
+
|
122 |
+
container.innerHTML = `<div class="loading">${message}</div>`;
|
123 |
+
}
|
124 |
+
|
125 |
+
updateLocationButtonState(isGetting) {
|
126 |
+
const locationBtn = document.getElementById('getLocation');
|
127 |
+
if (!locationBtn) return;
|
128 |
+
|
129 |
+
locationBtn.textContent = isGetting ? 'Getting...' : 'Get GPS Location';
|
130 |
+
locationBtn.disabled = isGetting;
|
131 |
+
}
|
132 |
+
|
133 |
+
highlightAutoFilledField(fieldId) {
|
134 |
+
const input = document.getElementById(fieldId);
|
135 |
+
if (input && !input.value.trim()) {
|
136 |
+
input.style.backgroundColor = '#f0f9ff';
|
137 |
+
setTimeout(() => {
|
138 |
+
input.style.backgroundColor = '';
|
139 |
+
}, 2000);
|
140 |
+
}
|
141 |
+
}
|
142 |
+
|
143 |
+
createFileInput(accept, capture = false) {
|
144 |
+
const input = document.createElement('input');
|
145 |
+
input.type = 'file';
|
146 |
+
input.accept = accept;
|
147 |
+
if (capture) {
|
148 |
+
input.capture = 'environment';
|
149 |
+
}
|
150 |
+
return input;
|
151 |
+
}
|
152 |
+
|
153 |
+
confirmDeletion(treeId) {
|
154 |
+
return confirm(`Are you sure you want to delete Tree #${treeId}? This action cannot be undone.`);
|
155 |
+
}
|
156 |
+
|
157 |
+
scrollToTop() {
|
158 |
+
window.scrollTo({ top: 0, behavior: 'smooth' });
|
159 |
+
}
|
160 |
+
|
161 |
+
focusFirstError() {
|
162 |
+
const firstError = document.querySelector('.form-input.error, .message.error');
|
163 |
+
if (firstError) {
|
164 |
+
firstError.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
165 |
+
if (firstError.focus) {
|
166 |
+
firstError.focus();
|
167 |
+
}
|
168 |
+
}
|
169 |
+
}
|
170 |
+
|
171 |
+
addFieldError(fieldId, message) {
|
172 |
+
const field = document.getElementById(fieldId);
|
173 |
+
if (!field) return;
|
174 |
+
|
175 |
+
field.classList.add('error');
|
176 |
+
|
177 |
+
// Remove existing error message
|
178 |
+
const existingError = field.parentNode.querySelector('.field-error');
|
179 |
+
if (existingError) {
|
180 |
+
existingError.remove();
|
181 |
+
}
|
182 |
+
|
183 |
+
// Add error message
|
184 |
+
const errorEl = document.createElement('div');
|
185 |
+
errorEl.className = 'field-error';
|
186 |
+
errorEl.textContent = message;
|
187 |
+
field.parentNode.appendChild(errorEl);
|
188 |
+
}
|
189 |
+
|
190 |
+
clearFieldErrors() {
|
191 |
+
document.querySelectorAll('.form-input.error').forEach(field => {
|
192 |
+
field.classList.remove('error');
|
193 |
+
});
|
194 |
+
|
195 |
+
document.querySelectorAll('.field-error').forEach(error => {
|
196 |
+
error.remove();
|
197 |
+
});
|
198 |
+
}
|
199 |
+
|
200 |
+
showUploadProgress(filename, progress) {
|
201 |
+
// This could be expanded for actual progress tracking
|
202 |
+
console.log(`Uploading ${filename}: ${progress}%`);
|
203 |
+
}
|
204 |
+
}
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
1 |
+
/**
|
2 |
+
* TreeTrack Enhanced Application - Modular Architecture
|
3 |
+
* Professional Field Research Tool with Authentication
|
4 |
+
*/
|
5 |
+
|
6 |
+
// Import all modules
|
7 |
+
import { AuthManager } from './modules/auth-manager.js';
|
8 |
+
import { ApiClient } from './modules/api-client.js';
|
9 |
+
import { UIManager } from './modules/ui-manager.js';
|
10 |
+
import { FormManager } from './modules/form-manager.js';
|
11 |
+
import { AutoCompleteManager } from './modules/autocomplete-manager.js';
|
12 |
+
import { MediaManager } from './modules/media-manager.js';
|
13 |
+
|
14 |
+
export class TreeTrackApp {
|
15 |
+
constructor() {
|
16 |
+
// Initialize core managers
|
17 |
+
this.authManager = new AuthManager();
|
18 |
+
this.apiClient = new ApiClient(this.authManager);
|
19 |
+
this.uiManager = new UIManager(this.authManager);
|
20 |
+
this.formManager = new FormManager(this.apiClient, this.uiManager);
|
21 |
+
this.autoCompleteManager = new AutoCompleteManager(this.apiClient, this.uiManager);
|
22 |
+
this.mediaManager = new MediaManager(this.formManager, this.uiManager);
|
23 |
+
|
24 |
+
this.init();
|
25 |
+
}
|
26 |
+
|
27 |
+
async init() {
|
28 |
+
try {
|
29 |
+
// Check authentication first
|
30 |
+
if (!await this.authManager.checkAuthentication()) {
|
31 |
+
window.location.href = '/login';
|
32 |
+
return;
|
33 |
+
}
|
34 |
+
|
35 |
+
// Initialize all managers
|
36 |
+
await this.initializeManagers();
|
37 |
+
|
38 |
+
// Setup event listeners
|
39 |
+
this.setupEventListeners();
|
40 |
+
|
41 |
+
// Load initial data
|
42 |
+
await this.loadInitialData();
|
43 |
+
|
44 |
+
} catch (error) {
|
45 |
+
console.error('Error initializing TreeTrack app:', error);
|
46 |
+
this.uiManager.showMessage('Error initializing application: ' + error.message, 'error');
|
47 |
+
}
|
48 |
+
}
|
49 |
+
|
50 |
+
async initializeManagers() {
|
51 |
+
// Initialize UI first
|
52 |
+
this.uiManager.initialize();
|
53 |
+
|
54 |
+
// Initialize form components
|
55 |
+
await this.formManager.initialize();
|
56 |
+
|
57 |
+
// Initialize media handling
|
58 |
+
this.mediaManager.initialize();
|
59 |
+
|
60 |
+
// Initialize autocomplete (with delay to ensure DOM is ready)
|
61 |
+
setTimeout(async () => {
|
62 |
+
await this.autoCompleteManager.initialize();
|
63 |
+
}, 100);
|
64 |
+
}
|
65 |
+
|
66 |
+
setupEventListeners() {
|
67 |
+
// Form submission
|
68 |
+
const form = document.getElementById('treeForm');
|
69 |
+
if (form) {
|
70 |
+
form.addEventListener('submit', (e) => this.handleFormSubmit(e));
|
71 |
+
}
|
72 |
+
|
73 |
+
// Reset form
|
74 |
+
const resetBtn = document.getElementById('resetForm');
|
75 |
+
if (resetBtn) {
|
76 |
+
resetBtn.addEventListener('click', () => this.handleFormReset());
|
77 |
+
}
|
78 |
+
|
79 |
+
// GPS location
|
80 |
+
const locationBtn = document.getElementById('getLocation');
|
81 |
+
if (locationBtn) {
|
82 |
+
locationBtn.addEventListener('click', () => this.mediaManager.getCurrentLocation());
|
83 |
+
}
|
84 |
+
|
85 |
+
// Logout functionality
|
86 |
+
const logoutBtn = document.getElementById('logoutBtn');
|
87 |
+
if (logoutBtn) {
|
88 |
+
logoutBtn.addEventListener('click', () => this.authManager.logout());
|
89 |
+
}
|
90 |
+
|
91 |
+
// Tree actions (edit/delete)
|
92 |
+
document.addEventListener('click', (e) => {
|
93 |
+
if (e.target.matches('.edit-tree')) {
|
94 |
+
const treeId = e.target.dataset.treeId;
|
95 |
+
if (treeId) {
|
96 |
+
this.handleEditTree(parseInt(treeId));
|
97 |
+
}
|
98 |
+
}
|
99 |
+
|
100 |
+
if (e.target.matches('.delete-tree')) {
|
101 |
+
const treeId = e.target.dataset.treeId;
|
102 |
+
if (treeId) {
|
103 |
+
this.handleDeleteTree(parseInt(treeId));
|
104 |
+
}
|
105 |
+
}
|
106 |
+
|
107 |
+
if (e.target.matches('#cancelEdit')) {
|
108 |
+
this.handleCancelEdit();
|
109 |
+
}
|
110 |
+
});
|
111 |
+
}
|
112 |
+
|
113 |
+
async loadInitialData() {
|
114 |
+
try {
|
115 |
+
await this.loadTrees();
|
116 |
+
} catch (error) {
|
117 |
+
console.error('Error loading initial data:', error);
|
118 |
+
}
|
119 |
+
}
|
120 |
+
|
121 |
+
async handleFormSubmit(e) {
|
122 |
+
e.preventDefault();
|
123 |
+
|
124 |
+
try {
|
125 |
+
// Clear any previous field errors
|
126 |
+
this.uiManager.clearFieldErrors();
|
127 |
+
|
128 |
+
// Validate form
|
129 |
+
this.formManager.validateForm();
|
130 |
+
|
131 |
+
// Get form data
|
132 |
+
const treeData = this.formManager.getFormData();
|
133 |
+
|
134 |
+
// Save or update tree
|
135 |
+
let result;
|
136 |
+
if (this.formManager.isInEditMode()) {
|
137 |
+
result = await this.apiClient.updateTree(this.formManager.getCurrentEditId(), treeData);
|
138 |
+
this.uiManager.showMessage(`Tree #${result.id} updated successfully!`, 'success');
|
139 |
+
this.handleCancelEdit();
|
140 |
+
} else {
|
141 |
+
result = await this.apiClient.saveTree(treeData);
|
142 |
+
this.uiManager.showMessage(
|
143 |
+
`Tree successfully added! Tree ID: ${result.id}. The form has been cleared for your next entry.`,
|
144 |
+
'success'
|
145 |
+
);
|
146 |
+
this.formManager.resetForm(true);
|
147 |
+
}
|
148 |
+
|
149 |
+
// Refresh tree list
|
150 |
+
await this.loadTrees();
|
151 |
+
this.uiManager.scrollToTop();
|
152 |
+
|
153 |
+
} catch (error) {
|
154 |
+
console.error('Error submitting form:', error);
|
155 |
+
this.uiManager.showMessage('Error saving tree: ' + error.message, 'error');
|
156 |
+
this.uiManager.focusFirstError();
|
157 |
+
}
|
158 |
+
}
|
159 |
+
|
160 |
+
handleFormReset() {
|
161 |
+
this.formManager.resetForm();
|
162 |
+
this.uiManager.clearFieldErrors();
|
163 |
+
}
|
164 |
+
|
165 |
+
async handleEditTree(treeId) {
|
166 |
+
try {
|
167 |
+
this.uiManager.showLoadingState('message', 'Loading tree data...');
|
168 |
+
|
169 |
+
const tree = await this.apiClient.loadTree(treeId);
|
170 |
+
if (!tree) return;
|
171 |
+
|
172 |
+
this.formManager.populateForm(tree);
|
173 |
+
this.formManager.setEditMode(treeId);
|
174 |
+
|
175 |
+
this.uiManager.showMessage(`Loaded tree #${treeId} for editing. Make changes and save.`, 'success');
|
176 |
+
this.uiManager.scrollToTop();
|
177 |
+
|
178 |
+
} catch (error) {
|
179 |
+
console.error('Error loading tree for edit:', error);
|
180 |
+
this.uiManager.showMessage('Error loading tree data: ' + error.message, 'error');
|
181 |
+
}
|
182 |
+
}
|
183 |
+
|
184 |
+
async handleDeleteTree(treeId) {
|
185 |
+
if (!this.uiManager.confirmDeletion(treeId)) {
|
186 |
+
return;
|
187 |
+
}
|
188 |
+
|
189 |
+
try {
|
190 |
+
await this.apiClient.deleteTree(treeId);
|
191 |
+
this.uiManager.showMessage(`Tree #${treeId} deleted successfully.`, 'success');
|
192 |
+
await this.loadTrees();
|
193 |
+
} catch (error) {
|
194 |
+
console.error('Error deleting tree:', error);
|
195 |
+
this.uiManager.showMessage('Error deleting tree: ' + error.message, 'error');
|
196 |
+
}
|
197 |
+
}
|
198 |
+
|
199 |
+
handleCancelEdit() {
|
200 |
+
this.formManager.resetForm(true);
|
201 |
+
this.formManager.exitEditMode();
|
202 |
+
this.uiManager.clearFieldErrors();
|
203 |
+
this.uiManager.showMessage('Edit cancelled. Form cleared.', 'success');
|
204 |
+
}
|
205 |
+
|
206 |
+
async loadTrees() {
|
207 |
+
try {
|
208 |
+
this.uiManager.showLoadingState('treeList', 'Loading trees...');
|
209 |
+
const trees = await this.apiClient.loadTrees();
|
210 |
+
this.uiManager.renderTreeList(trees);
|
211 |
+
} catch (error) {
|
212 |
+
console.error('Error loading trees:', error);
|
213 |
+
this.uiManager.showErrorState('treeList', 'Error loading trees');
|
214 |
+
}
|
215 |
+
}
|
216 |
+
|
217 |
+
// Utility methods for external access (maintaining compatibility)
|
218 |
+
showMessage(message, type) {
|
219 |
+
this.uiManager.showMessage(message, type);
|
220 |
+
}
|
221 |
+
|
222 |
+
// Global functions for backward compatibility (deprecated - use event delegation instead)
|
223 |
+
editTree(treeId) {
|
224 |
+
console.warn('Global editTree function is deprecated. Use data attributes instead.');
|
225 |
+
this.handleEditTree(treeId);
|
226 |
+
}
|
227 |
+
|
228 |
+
deleteTree(treeId) {
|
229 |
+
console.warn('Global deleteTree function is deprecated. Use data attributes instead.');
|
230 |
+
this.handleDeleteTree(treeId);
|
231 |
+
}
|
232 |
+
|
233 |
+
capturePhoto(category) {
|
234 |
+
console.warn('Global capturePhoto function is deprecated. Use data attributes instead.');
|
235 |
+
this.mediaManager.capturePhoto(category);
|
236 |
+
}
|
237 |
+
}
|
238 |
+
|
239 |
+
// Initialize the app when the page loads
|
240 |
+
let app;
|
241 |
+
document.addEventListener('DOMContentLoaded', () => {
|
242 |
+
app = new TreeTrackApp();
|
243 |
+
});
|
244 |
+
|
245 |
+
// Expose app globally for debugging and backward compatibility
|
246 |
+
window.TreeTrackApp = TreeTrackApp;
|
247 |
+
window.app = app;
|