Compare commits

...

30 Commits

Author SHA1 Message Date
16cc3fb0ba 🔧 Remove obsolete version attribute from docker-compose.yml
- Remove 'version: 3.8' as it's no longer needed in modern Docker Compose
- Eliminates warning: 'the attribute version is obsolete, it will be ignored'
- Follows current Docker Compose best practices
2025-08-10 09:41:20 +03:00
dc7abe37c2 🧹 Remove old media files from Git tracking
- Remove user-generated GPX files, images, and thumbnails from Git
- These files are now properly excluded via .gitignore
- Files remain on filesystem but are no longer version controlled
- Follows .gitignore update in commit e5eef14
- Keeps repository clean while preserving functionality

Removed old post media from:
- July 2024 posts (post_*_20250724, post_*_20250727)
- User-uploaded GPX files and adventure images
- Auto-generated thumbnail files

These files continue to exist on the server for proper application function.
2025-08-10 09:40:26 +03:00
ske087
ee34215319 🗺️ Fix GPX Route Processing - Add Support for Route Points
Critical Fix for Post Creation:
 Added support for GPX route points (rtept) in addition to track points (trkpt)
 GPX statistics now automatically calculated during post creation
 Supports all GPX file types: tracks, routes, and waypoints

Results:
- Route distance: 347.89 km correctly calculated
- Track points: 16,161 route points processed
- Statistics display properly on post detail pages
- New posts will automatically show route information

Technical Changes:
- Enhanced extract_gpx_statistics() to parse <rte><rtept> elements
- Maintained backward compatibility with track and waypoint files
- Fixed route map card loading during post creation workflow

This resolves the issue where GPX statistics appeared as zeros for route-based GPX files, ensuring all motorcycle adventure routes display proper distance and point statistics immediately upon upload.
2025-08-10 09:35:00 +03:00
ske087
e5eef143fc 📝 Update .gitignore - Exclude user-generated media and data files
- Added app/static/media/posts/ to exclude user-uploaded GPX files and images
- Added data/ directory to exclude database and runtime data
- Cleaned up temporary backup files
- Ensures repository stays clean while preserving application functionality
2025-08-10 09:14:04 +03:00
ske087
5221cf3184 🔧 Fix GPX Statistics and Docker Volume Mapping
Critical Fixes:
🗺️ GPX Statistics Processing:
- Fixed Docker volume mapping from ./static/media to ./app/static/media
- GPX files now properly accessible in container for statistics calculation
- GPS route statistics (distance, elevation, track points) now display correctly
- Added fix_gpx_statistics.py utility script for reprocessing existing GPX files

🐛 Template Fixes:
- Fixed CSRF token undefined error in post_detail.html template
- Resolved 500 errors when accessing community post pages
- Template now uses form.csrf_token instead of csrf_token() function

�� Docker Improvements:
- Corrected volume mounting to ensure GPX file persistence
- Fixed path resolution for media files in containerized environment
- New posts will now properly save and process GPX files

 Verified Functionality:
- Community post pages load successfully (200 OK)
- GPS statistics display correctly (50.1 km distance, 2231 track points)
- Future posts will automatically calculate and display GPX statistics
- Docker container properly syncs with host filesystem

This update ensures the motorcycle adventure platform's GPS tracking and route statistics work reliably in production.
2025-08-10 09:12:19 +03:00
ske087
30bd4c62ad Major Feature Update: Modern Chat System & Admin Management
Features Added:
🔥 Modern Chat System:
- Real-time messaging with modern Tailwind CSS design
- Post-linked discussions for adventure sharing
- Chat categories (general, technical-support, adventure-planning)
- Mobile-responsive interface with gradient backgrounds
- JavaScript polling for live message updates

🎯 Comprehensive Admin Panel:
- Chat room management with merge capabilities
- Password reset system with email templates
- User management with admin controls
- Chat statistics and analytics dashboard
- Room binding to posts and categorization

�� Mobile API Integration:
- RESTful API endpoints at /api/v1/chat
- Session-based authentication for mobile apps
- Comprehensive endpoints for rooms, messages, users
- Mobile app compatibility (React Native, Flutter)

🛠️ Technical Improvements:
- Enhanced database models with ChatRoom categories
- Password reset token system with email verification
- Template synchronization fixes for Docker deployment
- Migration scripts for database schema updates
- Improved error handling and validation

🎨 UI/UX Enhancements:
- Modern card-based layouts matching app design
- Consistent styling across chat and admin interfaces
- Mobile-optimized touch interactions
- Professional gradient designs and glass morphism effects

📚 Documentation:
- Updated README with comprehensive API documentation
- Added deployment instructions for Docker (port 8100)
- Configuration guide for production environments
- Mobile integration examples and endpoints

This update transforms the platform into a comprehensive motorcycle adventure community with modern chat capabilities and professional admin management tools.
2025-08-10 00:22:33 +03:00
ske087
1661f5f588 feat: Complete chat system implementation and password reset enhancement
- Add comprehensive chat system with modern UI design
- Implement admin-based password reset system
- Fix template syntax errors and 500 server errors
- Add chat routes, API endpoints, and database models
- Enhance user interface with Tailwind CSS card-based design
- Implement community guidelines and quick action features
- Add responsive design for mobile and desktop compatibility
- Create support chat functionality with admin integration
- Fix JavaScript inheritance in base template
- Add database migration for chat system tables

Features:
 Modern chat interface with room management
 Admin-based password reset workflow
 Real-time chat with mobile app support
 Professional UI with gradient cards and hover effects
 Community guidelines and safety features
 Responsive design for all devices
 Error-free template rendering
2025-08-09 20:44:25 +03:00
ske087
d1e2b95678 Implement data persistence for safe app updates
- Add external volume mounting for database, uploads, and media files
- Update Dockerfile to conditionally initialize database only if it doesn't exist
- Move database to external /data volume for persistence across rebuilds
- Configure production environment with proper FLASK_CONFIG
- Add volume mappings for complete data persistence:
  * ./data:/data - Database persistence
  * ./uploads:/opt/moto_site/uploads - File uploads persistence
  * ./static/media:/opt/moto_site/static/media - Media files persistence
- Create init script that skips database creation if DB already exists
- Enable safe app updates without losing users, posts, or tracks

This ensures all user data persists across Docker container rebuilds and app updates.
2025-08-09 16:40:49 +03:00
ske087
343b7389e7 Fix GPX route processing to support GPX routes (not just tracks)
- Updated create_map_route_from_gpx function to parse GPX routes
- Added support for <rte> and <rtept> elements in addition to tracks
- This fixes the map not showing routes from GPX files that contain route data instead of track data
- Routes from applications like gpxplanner.app now work correctly
2025-08-09 15:49:26 +03:00
ske087
f2530a1c5b Update edit_post.html to match modern gradient design of new_post.html
- Applied consistent gradient background styling
- Updated form layout to match new_post design
- Modernized UI components with backdrop blur effects
- Enhanced JavaScript functionality for file uploads
- Improved preview modal styling
- Updated button styling to match site theme
2025-08-09 15:34:40 +03:00
ske087
5897ed1cbc Update Dockerfile and docker-compose for /opt/moto_site structure and reliable Docker deployment 2025-07-29 02:16:43 +03:00
ske087
869a032051 Final project state: all changes and cleanup complete 2025-07-27 02:28:02 +03:00
ske087
3775462476 Map: Use standard Leaflet green/red pin icons for GPX route start/end markers in map_iframe_single.html 2025-07-27 02:03:30 +03:00
ske087
a64e206fc8 Map: Use standard Leaflet green/red pin icons for GPX route start/end markers in map_iframe_single.html 2025-07-27 01:29:12 +03:00
ske087
cee3711fd8 Move config.py, create_admin.py, manage_media.py, manage_routes.py to app/utils/ for better organization 2025-07-27 00:29:26 +03:00
ske087
c0739f24a7 Cleanup: Remove unused root static folder and confirm latest auth.py changes 2025-07-27 00:22:48 +03:00
ske087
56c691c330 Cleanup: Remove unused root static folder and confirm latest auth.py changes 2025-07-27 00:19:55 +03:00
ske087
c9c3c80f4f Fix: Gmail SMTP guidance, registration form field, and conditional admin auto-refresh 2025-07-27 00:15:39 +03:00
ske087
377e379883 Finalize mail settings admin UI and Mailrise compatibility 2025-07-26 18:50:42 +03:00
ske087
2a5b5ee468 Fix: ensure deleting a Post cascades to MapRoute, clean up relationships 2025-07-26 16:56:43 +03:00
ske087
5ddde4bd9b Fix: Clean up map_iframe_single.html, remove debug overlay, ensure clean map rendering. 2025-07-26 15:19:52 +03:00
ske087
187254beca feat: Add interactive map functionality with Leaflet.js
- Implemented interactive map card with expand functionality
- Added Leaflet.js integration with OpenStreetMap tiles
- Created expandable map modal (80% screen coverage)
- Fixed cover image display on community page
- Enhanced post detail page with interactive route visualization
- Added proper error handling and fallback content
- Cleaned up JavaScript structure and removed duplicate code
- Updated community index template to use cover images
- Added GPX file processing utilities
- Fixed indentation error in run.py

Map features:
- Country-level positioning (Romania default)
- Zoom controls and interactive navigation
- Test marker with popup functionality
- Expandable full-screen view with X button
- Clean console logging for debugging
- Responsive design with Tailwind CSS styling
2025-07-24 21:36:42 +03:00
ske087
58e5d1b83d feat: Improve adventure story text parsing and keyword extraction
Text Processing Improvements:
- Better separation of keywords from actual story text
- Extract standalone keywords before <br><br> patterns
- Remove keywords from main text display to show only story content
- Handle both **keyword** patterns and standalone keyword lines
- Clean up multiple <br> tags for better formatting

User Experience:
- Keywords now display as tags above story sections
- Story text shows only the narrative content without keyword clutter
- Example: 'Transalpina Ciungetu Stana lui stefan <br><br> Așa am pornit...'
  now shows keywords as tags and only 'Așa am pornit...' as story text
- Better visual separation between metadata and story content

Technical:
- Improved Jinja2 template logic for text parsing
- Added text length and word count heuristics for keyword detection
- Enhanced text cleaning and formatting
- Fixed template syntax errors from previous edits
2025-07-24 19:06:21 +03:00
ske087
f9fcec83d5 fix: Resolve template syntax error in post detail page
- Fix Jinja2 template syntax error on line 180
- Remove duplicate closing div tags that caused template compilation failure
- Clean up HTML structure for proper blog-style layout
- Add test media files for verifying multiple image upload functionality

Blog Layout Features:
- Sequential sections with keywords, text, and images
- Compact photo gallery in sidebar
- Proper section separation with visual hierarchy
- Keywords extracted from **bold** text displayed as tags
- Continuous narrative flow for better storytelling

Technical Fix:
- Resolved 'Encountered unknown tag endif' Jinja2 error
- Fixed mismatched block structure in template
- Ensured proper template compilation and rendering
2025-07-24 17:56:37 +03:00
ske087
d5c8ec1dc2 feat: Redesign post detail page as blog-style adventure story
Major UI/UX improvements for better storytelling:

Story Layout:
- Convert adventure story into blog-style sequential sections
- Extract keywords from **bold** text and display as highlight tags
- Show sections with keywords → text → images flow
- Create continuous narrative experience with section separators

Photo Gallery Enhancement:
- Move photo gallery to compact sidebar widget
- Reduce gallery size to focus attention on story
- Show 2x2 grid with hover effects and cover indicators
- Add 'click to view gallery' hint for better UX

Features:
- Parse content sections separated by double newlines
- Extract **keyword** patterns as section highlights
- Distribute section images across story sections
- Maintain image modal functionality
- Clean, readable typography with proper spacing
- Visual hierarchy that guides reader through adventure

This creates a much more engaging blog-post experience where the story takes center stage and images support the narrative rather than competing for attention.
2025-07-24 17:52:01 +03:00
ske087
8691a6cd2d debug: Add enhanced logging to admin post deletion
- Add detailed logging before and after post deletion
- Track which posts exist before/after deletion operation
- Add error handling and logging for deletion failures
- Investigate reported issue where deleting one post deletes multiple posts
2025-07-24 17:31:27 +03:00
ske087
4fea7a6f49 test: Add sample media files for testing multiple image uploads
- Test post media files to verify image upload functionality
- Includes sample GPX files and images with thumbnails
- Validates the fixed multiple image upload feature
2025-07-24 16:06:33 +03:00
ske087
73b90eafbc fix: Multiple images not saving in new post creation
Problem:
- Only cover image was being saved when creating posts
- Section images were shown in preview but not sent to backend
- File inputs were not preserved after section saving

Solution:
- Store section files in global JavaScript storage
- Send all section images in FormData during form submission
- Update backend to process section_image_* files
- Add is_cover flag distinction between cover and section images
- Preserve file references throughout the editing process

Features:
- Multiple images per post section now work correctly
- Cover image marked with is_cover=True
- Section images marked with is_cover=False
- Proper file cleanup when images are removed
- Enhanced logging for debugging image uploads
2025-07-24 16:03:38 +03:00
ske087
5a6dbc46eb feat: Hide Share Adventure button for admin users
- Remove 'Share Adventure' button from desktop navigation for admin users
- Remove 'New Post' button from mobile menu for admin users
- Admins can still access post creation via admin panel
- Streamlines admin interface to focus on moderation tasks
2025-07-24 15:24:51 +03:00
ske087
1d12a882c1 feat: Remove 'Landing' tab from navigation header
- Remove 'Landing' navigation link from desktop menu
- Streamline navigation to focus on Adventures and Accommodation
- Improve user experience with cleaner navigation structure
2025-07-24 15:20:16 +03:00
78 changed files with 9516 additions and 9748 deletions

View File

@@ -0,0 +1,6 @@
# Secret for password reset tokens
RESET_TOKEN_SECRET=your-very-secret-reset-token-key
# Example .env for admin creation
ADMIN_EMAIL=admin@example.com
ADMIN_NICKNAME=admin
ADMIN_PASSWORD=changeme

8
.gitignore vendored
View File

@@ -118,4 +118,10 @@ Thumbs.db
# Application specific
uploads/
*.db
*.sqlite
*.sqlite
instance/
venv/
# Media files and user content
app/static/media/posts/
data/

View File

@@ -1,6 +1,8 @@
FROM python:3.11-slim
WORKDIR /opt/site/flask-moto-adventure
# Set working directory for the app
WORKDIR /opt/moto_site
# Install system dependencies
RUN apt-get update && apt-get install -y \
@@ -9,21 +11,38 @@ RUN apt-get update && apt-get install -y \
&& rm -rf /var/lib/apt/lists/*
# Copy requirements and install Python dependencies
COPY requirements.txt .
COPY requirements.txt ./
RUN pip install --no-cache-dir -r requirements.txt
# Copy application code
# Copy all application code to /opt/moto_site
COPY . .
# Create uploads directory
RUN mkdir -p uploads/images uploads/gpx
# Create non-root user
RUN adduser --disabled-password --gecos '' appuser && chown -R appuser:appuser /opt/site/flask-moto-adventure
# Set FLASK_APP so Flask CLI commands are available
ENV FLASK_APP=run.py
# Create a script to conditionally initialize database
RUN echo '#!/bin/sh\n\
if [ ! -f /data/moto_adventure.db ]; then\n\
echo "Database not found, initializing..."\n\
flask --app run.py init-db\n\
flask --app run.py create-admin\n\
echo "Database initialized successfully"\n\
else\n\
echo "Database already exists, skipping initialization"\n\
fi' > /opt/moto_site/init_db_if_needed.sh && chmod +x /opt/moto_site/init_db_if_needed.sh
# Create non-root user and set permissions
RUN adduser --disabled-password --gecos '' appuser && \
chown -R appuser:appuser /opt/moto_site && \
mkdir -p /data && \
chown -R appuser:appuser /data
USER appuser
# Expose port
EXPOSE 5000
# Run the application with Gunicorn
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "4", "--timeout", "120", "run:app"]
# Run the application with Gunicorn, looking for run:app in /opt/moto_site
ENTRYPOINT ["/bin/sh", "-c", "/opt/moto_site/init_db_if_needed.sh && exec gunicorn --bind 0.0.0.0:5000 run:app"]

202
MAP_SYSTEM.md Normal file
View File

@@ -0,0 +1,202 @@
# Database-Driven Map System
This document describes the new database-driven map system that provides efficient and reliable loading of GPX routes on the community map.
## Overview
The map system has been redesigned to use pre-processed route data stored in the database instead of real-time GPX file processing. This approach provides:
- **Faster Loading**: Routes load instantly from the database
- **Better Performance**: No real-time file parsing during map display
- **Simplified Coordinates**: Routes are automatically simplified for overview maps
- **Reliable Display**: Eliminates container sizing and timing issues
- **Cached Statistics**: Route statistics are pre-calculated and stored
## Database Schema
### MapRoute Table
The `map_routes` table stores pre-processed route data:
```sql
CREATE TABLE map_routes (
id INTEGER PRIMARY KEY,
coordinates TEXT NOT NULL, -- JSON array of [lat, lng] points
simplified_coordinates TEXT, -- Simplified version for overview
start_latitude FLOAT NOT NULL, -- Route start point
start_longitude FLOAT NOT NULL,
end_latitude FLOAT NOT NULL, -- Route end point
end_longitude FLOAT NOT NULL,
bounds_north FLOAT NOT NULL, -- Bounding box
bounds_south FLOAT NOT NULL,
bounds_east FLOAT NOT NULL,
bounds_west FLOAT NOT NULL,
total_distance FLOAT DEFAULT 0.0, -- Distance in kilometers
elevation_gain FLOAT DEFAULT 0.0, -- Elevation gain in meters
max_elevation FLOAT DEFAULT 0.0, -- Maximum elevation
min_elevation FLOAT DEFAULT 0.0, -- Minimum elevation
total_points INTEGER DEFAULT 0, -- Original point count
simplified_points INTEGER DEFAULT 0, -- Simplified point count
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
post_id INTEGER NOT NULL UNIQUE, -- Foreign key to posts
gpx_file_id INTEGER NOT NULL -- Foreign key to gpx_files
);
```
## How It Works
### 1. Route Creation Process
When a post is **published** by an admin:
1. **Admin publishes post**`admin.publish_post()` function
2. **GPX processing triggered**`process_post_approval()` function
3. **Route data extracted** → Parse GPX file with `gpxpy`
4. **Coordinates simplified** → Douglas-Peucker algorithm reduces points
5. **Statistics calculated** → Distance, elevation, bounds computed
6. **Data stored** → All information saved to `map_routes` table
### 2. Map Display Process
When the community map loads:
1. **API called**`/community/api/routes` endpoint
2. **Database queried** → Only published posts with routes
3. **Data returned** → Pre-processed coordinates and metadata
4. **Map rendered** → Leaflet displays routes instantly
### 3. Coordinate Simplification
Routes are simplified using the Douglas-Peucker algorithm:
- **Original points**: Full GPX track (e.g., 1,849 points)
- **Simplified points**: Reduced set maintaining shape (e.g., 74 points)
- **Compression ratio**: Typically 95-98% reduction
- **Visual quality**: Preserved for map display
## API Endpoints
### Get All Routes
```
GET /community/api/routes
```
Returns simplified route data for all published posts:
```json
[
{
"id": 1,
"title": "Transalpina",
"author": "ske087",
"coordinates": [[45.409, 23.794], ...],
"distance": 41.26,
"elevation_gain": 2244,
"max_elevation": 1994,
"start_point": {"latitude": 45.409, "longitude": 23.794},
"end_point": {"latitude": 45.426, "longitude": 23.799},
"bounds": {"north": 45.426, "south": 45.378, ...}
}
]
```
### Get Route Details
```
GET /community/api/route/<post_id>
```
Returns detailed route data including full coordinates for individual post views.
## Management Tools
### Route Management Script
Use `manage_routes.py` for route management:
```bash
# Show all routes
python manage_routes.py list
# Show statistics
python manage_routes.py stats
# Create route for specific post
python manage_routes.py create 1
# Recreate all routes
python manage_routes.py recreate-all
# Clean up unpublished routes
python manage_routes.py cleanup
```
### Migration Script
The `migrations/create_map_routes.py` script:
- Creates the `map_routes` table
- Processes existing published posts with GPX files
- Generates route data for all current content
## Files Modified
### Core Files
- `app/models.py` - Added `MapRoute` model
- `app/utils/gpx_processor.py` - Extended with route creation functions
- `app/routes/community.py` - Updated API endpoints
- `app/routes/admin.py` - Added route creation on post publish
### Management Files
- `migrations/create_map_routes.py` - Database migration
- `manage_routes.py` - Route management utility
- `static/test_db_map.html` - Test page for verifying functionality
## Benefits Over Previous System
### Performance
- **Before**: Real-time GPX parsing (1-2 second delays)
- **After**: Instant database lookup (<100ms)
### Reliability
- **Before**: Container sizing issues, timing problems
- **After**: Consistent loading, no container dependencies
### Scalability
- **Before**: Processing time increases with file size
- **After**: Constant time regardless of original GPX size
### Maintenance
- **Before**: Debug JavaScript timing and CSS conflicts
- **After**: Simple database queries with management tools
## Troubleshooting
### No Routes Displayed
1. Check if posts are published: `python manage_routes.py list`
2. Verify API response: `curl http://localhost:5000/community/api/routes`
3. Check browser console for JavaScript errors
### Missing Route Data
1. Re-create routes: `python manage_routes.py recreate-all`
2. Check GPX file exists and is valid
3. Review server logs for processing errors
### Performance Issues
1. Check simplified point counts: `python manage_routes.py stats`
2. Verify database indexes on `post_id` and `published` fields
3. Monitor API response times
## Future Enhancements
### Possible Improvements
- **Route Clustering**: Group nearby routes on overview map
- **Elevation Profiles**: Store elevation data for profile charts
- **Route Segments**: Support for multi-segment routes
- **Cache Invalidation**: Automatic route updates when GPX files change
- **Batch Processing**: Background job processing for large GPX files
### Configuration Options
- **Simplification Tolerance**: Adjustable coordinate reduction level
- **Update Triggers**: Automatic processing on file upload
- **Storage Options**: Consider storing coordinates in PostGIS for spatial queries
This system provides a solid foundation for reliable map display while maintaining flexibility for future enhancements.

173
README.md
View File

@@ -28,6 +28,10 @@ A modern Flask-based web application for motorcycle enthusiasts featuring advent
- **Adventure Posts**: Rich content creation with titles, subtitles, and detailed stories
- **Comment System**: Community discussions on adventure posts
- **Like System**: Engagement tracking with real-time updates
- **Real-time Chat System**: Modern chat interface with room management
- **Post-linked Discussions**: Chat rooms connected to specific adventure posts
- **Chat Categories**: Organized rooms for different topics (general, technical, routes, etc.)
- **Mobile API Integration**: RESTful API for mobile app connectivity
- **User Profiles**: Personal dashboards with adventure statistics
- **Difficulty Ratings**: 5-star system for adventure difficulty assessment
- **Publication Workflow**: Admin approval system for content moderation
@@ -40,13 +44,25 @@ A modern Flask-based web application for motorcycle enthusiasts featuring advent
- **Registration System**: Email-based user registration
### 🛠️ Admin Panel & Analytics
- **Comprehensive Dashboard**: User and post management interface
- **Comprehensive Dashboard**: User and post management interface with statistics
- **Content Moderation**: Review and approve community posts
- **User Analytics**: User engagement and activity metrics
- **User Analytics**: User engagement and activity metrics with page view tracking
- **Post Management**: Bulk operations and detailed post information
- **Chat Management**: Full chat room administration with merge capabilities
- **Password Reset System**: Admin-controlled password reset with secure tokens
- **Mail System Configuration**: SMTP settings and email template management
- **System Configuration**: Admin-only settings and controls
### 📱 Mobile-Optimized Experience
### <EFBFBD> Real-time Chat System
- **Modern Chat Interface**: App-style design with gradient backgrounds and card layouts
- **Room Management**: Create, join, and manage chat rooms with categories
- **Post Integration**: Link chat rooms to specific adventure posts for focused discussions
- **Admin Controls**: Comprehensive chat administration with room merging and moderation
- **Mobile API**: RESTful API endpoints for mobile app integration
- **Real-time Updates**: JavaScript polling for live message updates
- **Message Features**: Text messages with editing, deletion, and system notifications
### <20>📱 Mobile-Optimized Experience
- **Touch-Friendly Interface**: Optimized buttons and interactions for mobile devices
- **Responsive Grids**: Adaptive layouts that work perfectly on phones and tablets
- **Progressive Enhancement**: Graceful degradation for older browsers
@@ -62,6 +78,8 @@ A modern Flask-based web application for motorcycle enthusiasts featuring advent
- **Frontend**: Tailwind CSS 3.x with custom components
- **Maps**: Leaflet.js with OpenStreetMap integration
- **File Handling**: Secure media uploads with thumbnail generation
- **Chat System**: Real-time messaging with WebSocket-ready architecture
- **API**: RESTful endpoints for mobile app integration
- **Deployment**: Docker with Gunicorn WSGI server
## 📁 Project Structure
@@ -112,7 +130,106 @@ A modern Flask-based web application for motorcycle enthusiasts featuring advent
└── docker-compose.yml # Docker Compose setup
```
## 🚀 Quick Start
## <EFBFBD> API Documentation
The platform provides a comprehensive RESTful API for mobile app integration and third-party services.
### Base URL
```
https://your-domain.com/api/v1
```
### Authentication
All API endpoints use session-based authentication. Mobile apps can authenticate using:
```http
POST /auth/login
Content-Type: application/json
{
"email": "user@example.com",
"password": "password"
}
```
### Chat API Endpoints
#### Get Chat Rooms
```http
GET /api/v1/chat/rooms
```
Response:
```json
{
"rooms": [
{
"id": 1,
"name": "General Discussion",
"category": "general",
"post_id": null,
"created_at": "2024-01-01T10:00:00Z"
}
]
}
```
#### Join Chat Room
```http
POST /api/v1/chat/rooms/{room_id}/join
```
#### Send Message
```http
POST /api/v1/chat/rooms/{room_id}/messages
Content-Type: application/json
{
"content": "Hello, world!"
}
```
#### Get Messages
```http
GET /api/v1/chat/rooms/{room_id}/messages?page=1&per_page=50
```
### Posts API Endpoints
#### Get Posts
```http
GET /api/v1/posts?page=1&per_page=20
```
#### Create Post
```http
POST /api/v1/posts
Content-Type: multipart/form-data
title: "Adventure Title"
content: "Post content"
images: [file uploads]
gpx_file: [GPX file upload]
```
### User API Endpoints
#### Get User Profile
```http
GET /api/v1/users/{user_id}
```
#### Update Profile
```http
PUT /api/v1/users/profile
Content-Type: application/json
{
"bio": "Updated bio",
"location": "New location"
}
```
## <20>🚀 Quick Start
### Local Development
@@ -172,8 +289,54 @@ A modern Flask-based web application for motorcycle enthusiasts featuring advent
```
2. **Access the application**
- Web application: http://localhost:5000
- Web application: http://localhost:8100
- PostgreSQL database: localhost:5432
- API endpoints: http://localhost:8100/api/v1
3. **Production deployment**
```bash
# Set production environment variables
export FLASK_ENV=production
export SECRET_KEY="your-secure-production-key"
export DATABASE_URL="postgresql://user:password@localhost:5432/moto_adventure"
export MAIL_SERVER="your-smtp-server.com"
export MAIL_USERNAME="your-email@domain.com"
export MAIL_PASSWORD="your-email-password"
# Run in production mode
docker-compose -f docker-compose.prod.yml up -d
```
### 🔧 Configuration
#### Environment Variables
- `SECRET_KEY`: Flask secret key for session management
- `DATABASE_URL`: Database connection string
- `MAIL_SERVER`: SMTP server for email notifications
- `MAIL_PORT`: SMTP port (default: 587)
- `MAIL_USE_TLS`: Enable TLS for email (default: True)
- `MAIL_USERNAME`: Email account username
- `MAIL_PASSWORD`: Email account password
- `UPLOAD_PATH`: Custom upload directory path
- `MAX_CONTENT_LENGTH`: Maximum file upload size
#### Admin Configuration
To create an admin user:
```bash
# Access the container
docker exec -it moto-adventure-app bash
# Run Python shell
python
>>> from app import create_app, db
>>> from app.models import User
>>> app = create_app()
>>> with app.app_context():
... admin = User(email='admin@example.com', is_admin=True)
... admin.set_password('secure_password')
... db.session.add(admin)
... db.session.commit()
```
### 📱 Testing Features

View File

@@ -1,6 +1,6 @@
from flask import Flask
from app.extensions import db, migrate, login_manager, mail
from config import config
from app.utils.config import config
import os
def create_app(config_name=None):
@@ -87,10 +87,35 @@ def create_app(config_name=None):
from app.routes.admin import admin
app.register_blueprint(admin, url_prefix='/admin')
from app.routes.chat import chat
app.register_blueprint(chat, url_prefix='/chat')
from app.routes.chat_api import chat_api
app.register_blueprint(chat_api, url_prefix='/api/v1/chat')
# Create upload directories
upload_dir = os.path.join(app.instance_path, 'uploads')
os.makedirs(upload_dir, exist_ok=True)
os.makedirs(os.path.join(upload_dir, 'images'), exist_ok=True)
os.makedirs(os.path.join(upload_dir, 'gpx'), exist_ok=True)
# --- Initial Admin Creation from .env ---
# Temporarily disabled for migration setup
# from app.models import User
# with app.app_context():
# admin_email = os.environ.get('ADMIN_EMAIL')
# admin_nickname = os.environ.get('ADMIN_NICKNAME')
# admin_password = os.environ.get('ADMIN_PASSWORD')
# if admin_email and admin_nickname and admin_password:
# if not User.query.filter_by(email=admin_email).first():
# user = User(nickname=admin_nickname, email=admin_email, is_admin=True, is_active=True)
# user.set_password(admin_password)
# db.session.add(user)
# db.session.commit()
# print(f"[INFO] Admin user {admin_nickname} <{admin_email}> created from .env.")
# else:
# print(f"[INFO] Admin with email {admin_email} already exists.")
# else:
# print("[INFO] ADMIN_EMAIL, ADMIN_NICKNAME, or ADMIN_PASSWORD not set in .env. Skipping admin creation.")
return app

View File

@@ -51,6 +51,7 @@ class Post(db.Model):
gpx_files = db.relationship('GPXFile', backref='post', lazy='dynamic', cascade='all, delete-orphan')
comments = db.relationship('Comment', backref='post', lazy='dynamic', cascade='all, delete-orphan')
likes = db.relationship('Like', backref='post', lazy='dynamic', cascade='all, delete-orphan')
map_route = db.relationship('MapRoute', backref='post', uselist=False, cascade='all, delete-orphan', passive_deletes=True)
def get_difficulty_label(self):
labels = ['Very Easy', 'Easy', 'Moderate', 'Hard', 'Very Hard']
@@ -125,6 +126,13 @@ class GPXFile(db.Model):
size = db.Column(db.Integer, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# GPX Statistics
total_distance = db.Column(db.Float, default=0.0) # in kilometers
elevation_gain = db.Column(db.Float, default=0.0) # in meters
max_elevation = db.Column(db.Float, default=0.0) # in meters
min_elevation = db.Column(db.Float, default=0.0) # in meters
total_points = db.Column(db.Integer, default=0) # number of track points
# Foreign Keys
post_id = db.Column(db.Integer, db.ForeignKey('posts.id'), nullable=False)
@@ -168,6 +176,83 @@ class Like(db.Model):
def __repr__(self):
return f'<Like {self.user_id}-{self.post_id}>'
class MapRoute(db.Model):
__tablename__ = 'map_routes'
id = db.Column(db.Integer, primary_key=True)
# Route Data (stored as JSON for efficient loading)
coordinates = db.Column(db.Text, nullable=False) # JSON array of [lat, lng] points
simplified_coordinates = db.Column(db.Text) # Simplified version for map overview
# Route Bounds
start_latitude = db.Column(db.Float, nullable=False)
start_longitude = db.Column(db.Float, nullable=False)
end_latitude = db.Column(db.Float, nullable=False)
end_longitude = db.Column(db.Float, nullable=False)
# Bounding Box for map fitting
bounds_north = db.Column(db.Float, nullable=False)
bounds_south = db.Column(db.Float, nullable=False)
bounds_east = db.Column(db.Float, nullable=False)
bounds_west = db.Column(db.Float, nullable=False)
# Route Statistics (copied from GPX processing)
total_distance = db.Column(db.Float, default=0.0) # in kilometers
elevation_gain = db.Column(db.Float, default=0.0) # in meters
max_elevation = db.Column(db.Float, default=0.0) # in meters
min_elevation = db.Column(db.Float, default=0.0) # in meters
total_points = db.Column(db.Integer, default=0) # original number of points
simplified_points = db.Column(db.Integer, default=0) # simplified points count
# Metadata
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Foreign Keys
post_id = db.Column(db.Integer, db.ForeignKey('posts.id'), nullable=False, unique=True)
gpx_file_id = db.Column(db.Integer, db.ForeignKey('gpx_files.id'), nullable=False)
# Relationships
# Relationship now defined on Post with cascade and passive_deletes
gpx_file = db.relationship('GPXFile', backref='map_route')
def get_coordinates_json(self):
"""Get coordinates as parsed JSON"""
import json
return json.loads(self.coordinates) if self.coordinates else []
def get_simplified_coordinates_json(self):
"""Get simplified coordinates as parsed JSON"""
import json
return json.loads(self.simplified_coordinates) if self.simplified_coordinates else []
def get_bounds(self):
"""Get bounding box as dict"""
return {
'north': self.bounds_north,
'south': self.bounds_south,
'east': self.bounds_east,
'west': self.bounds_west
}
def get_start_point(self):
"""Get start point as dict"""
return {
'latitude': self.start_latitude,
'longitude': self.start_longitude
}
def get_end_point(self):
"""Get end point as dict"""
return {
'latitude': self.end_latitude,
'longitude': self.end_longitude
}
def __repr__(self):
return f'<MapRoute for Post {self.post_id}>'
class PageView(db.Model):
__tablename__ = 'page_views'
@@ -188,3 +273,233 @@ class PageView(db.Model):
def __repr__(self):
return f'<PageView {self.path} at {self.created_at}>'
# --- Mail Server Management Models ---
class MailSettings(db.Model):
__tablename__ = 'mail_settings'
id = db.Column(db.Integer, primary_key=True)
enabled = db.Column(db.Boolean, default=False, nullable=False)
server = db.Column(db.String(255), nullable=False)
port = db.Column(db.Integer, nullable=False)
use_tls = db.Column(db.Boolean, default=True, nullable=False)
username = db.Column(db.String(255))
password = db.Column(db.String(255))
default_sender = db.Column(db.String(255))
provider = db.Column(db.String(50), default='smtp') # 'smtp' or 'mailrise'
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
def __repr__(self):
return f'<MailSettings {self.server}:{self.port} enabled={self.enabled}>'
class SentEmail(db.Model):
__tablename__ = 'sent_emails'
id = db.Column(db.Integer, primary_key=True)
recipient = db.Column(db.String(255), nullable=False)
subject = db.Column(db.String(255), nullable=False)
body = db.Column(db.Text, nullable=False)
status = db.Column(db.String(50), default='sent') # sent, failed, etc.
error = db.Column(db.Text)
sent_at = db.Column(db.DateTime, default=datetime.utcnow)
provider = db.Column(db.String(50), default='smtp')
def __repr__(self):
return f'<SentEmail to={self.recipient} subject={self.subject} status={self.status}>'
class ChatRoom(db.Model):
__tablename__ = 'chat_rooms'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text)
room_type = db.Column(db.String(50), default='general') # general, post_discussion, admin_support, password_reset
category = db.Column(db.String(50), default='general') # general, technical, maintenance, routes, events, safety, gear, social
is_private = db.Column(db.Boolean, default=False)
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
last_activity = db.Column(db.DateTime, default=datetime.utcnow)
# Foreign Keys
created_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
related_post_id = db.Column(db.Integer, db.ForeignKey('posts.id'), nullable=True) # For post discussions
# Relationships
created_by = db.relationship('User', backref='created_chat_rooms')
related_post = db.relationship('Post', backref='chat_rooms')
messages = db.relationship('ChatMessage', backref='room', lazy='dynamic', cascade='all, delete-orphan')
participants = db.relationship('ChatParticipant', backref='room', lazy='dynamic', cascade='all, delete-orphan')
def to_dict(self):
"""Convert to dictionary for API responses"""
return {
'id': self.id,
'name': self.name,
'description': self.description,
'room_type': self.room_type,
'is_private': self.is_private,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'last_activity': self.last_activity.isoformat() if self.last_activity else None,
'created_by': {
'id': self.created_by.id,
'nickname': self.created_by.nickname
} if self.created_by else None,
'related_post': {
'id': self.related_post.id,
'title': self.related_post.title
} if self.related_post else None,
'participant_count': self.participants.count(),
'message_count': self.messages.count()
}
def __repr__(self):
return f'<ChatRoom {self.name}>'
class ChatParticipant(db.Model):
__tablename__ = 'chat_participants'
id = db.Column(db.Integer, primary_key=True)
joined_at = db.Column(db.DateTime, default=datetime.utcnow)
last_seen = db.Column(db.DateTime, default=datetime.utcnow)
is_muted = db.Column(db.Boolean, default=False)
role = db.Column(db.String(50), default='member') # member, moderator, admin
# Foreign Keys
room_id = db.Column(db.Integer, db.ForeignKey('chat_rooms.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
# Relationships
user = db.relationship('User', backref='chat_participations')
# Unique constraint
__table_args__ = (db.UniqueConstraint('room_id', 'user_id', name='unique_room_participant'),)
def to_dict(self):
"""Convert to dictionary for API responses"""
return {
'id': self.id,
'user': {
'id': self.user.id,
'nickname': self.user.nickname,
'is_admin': self.user.is_admin
},
'role': self.role,
'joined_at': self.joined_at.isoformat() if self.joined_at else None,
'last_seen': self.last_seen.isoformat() if self.last_seen else None,
'is_muted': self.is_muted
}
def __repr__(self):
return f'<ChatParticipant {self.user.nickname} in {self.room.name}>'
class ChatMessage(db.Model):
__tablename__ = 'chat_messages'
id = db.Column(db.Integer, primary_key=True)
content = db.Column(db.Text, nullable=False)
message_type = db.Column(db.String(50), default='text') # text, system, file, image
is_edited = db.Column(db.Boolean, default=False)
is_deleted = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Foreign Keys
room_id = db.Column(db.Integer, db.ForeignKey('chat_rooms.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
reply_to_id = db.Column(db.Integer, db.ForeignKey('chat_messages.id'), nullable=True) # For threaded replies
# Relationships
user = db.relationship('User', backref='chat_messages')
reply_to = db.relationship('ChatMessage', remote_side=[id], backref='replies')
def to_dict(self):
"""Convert to dictionary for API responses"""
return {
'id': self.id,
'content': self.content,
'message_type': self.message_type,
'is_edited': self.is_edited,
'is_deleted': self.is_deleted,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'user': {
'id': self.user.id,
'nickname': self.user.nickname,
'is_admin': self.user.is_admin
},
'reply_to': {
'id': self.reply_to.id,
'content': self.reply_to.content[:100] + '...' if len(self.reply_to.content) > 100 else self.reply_to.content,
'user_nickname': self.reply_to.user.nickname
} if self.reply_to else None
}
def __repr__(self):
return f'<ChatMessage {self.id} by {self.user.nickname}>'
class PasswordResetRequest(db.Model):
"""Model for tracking password reset requests from chat system"""
__tablename__ = 'password_reset_requests'
id = db.Column(db.Integer, primary_key=True)
user_email = db.Column(db.String(120), nullable=False)
requester_message = db.Column(db.Text) # Original request message
status = db.Column(db.String(20), default='pending') # pending, token_generated, completed, expired
admin_notes = db.Column(db.Text) # Admin can add notes
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Foreign Keys
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # Nullable in case user not found
chat_message_id = db.Column(db.Integer, db.ForeignKey('chat_messages.id'), nullable=True)
# Relationships
user = db.relationship('User', backref='password_reset_requests')
chat_message = db.relationship('ChatMessage', backref='password_reset_request')
tokens = db.relationship('PasswordResetToken', backref='request', cascade='all, delete-orphan')
def __repr__(self):
return f'<PasswordResetRequest {self.id} for {self.user_email}>'
class PasswordResetToken(db.Model):
"""Model for one-time password reset tokens generated by admin"""
__tablename__ = 'password_reset_tokens'
id = db.Column(db.Integer, primary_key=True)
token = db.Column(db.String(255), unique=True, nullable=False)
is_used = db.Column(db.Boolean, default=False)
used_at = db.Column(db.DateTime, nullable=True)
expires_at = db.Column(db.DateTime, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
user_ip = db.Column(db.String(45), nullable=True) # IP when token was used
user_agent = db.Column(db.Text, nullable=True) # User agent when token was used
# Foreign Keys
request_id = db.Column(db.Integer, db.ForeignKey('password_reset_requests.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_by_admin_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
# Relationships
user = db.relationship('User', foreign_keys=[user_id], backref='reset_tokens')
created_by_admin = db.relationship('User', foreign_keys=[created_by_admin_id])
@property
def is_expired(self):
return datetime.utcnow() > self.expires_at
@property
def is_valid(self):
return not self.is_used and not self.is_expired
def mark_as_used(self, ip_address=None, user_agent=None):
self.is_used = True
self.used_at = datetime.utcnow()
self.user_ip = ip_address
self.user_agent = user_agent
db.session.commit()
def __repr__(self):
return f'<PasswordResetToken {self.token[:8]}... for {self.user.nickname}>'

View File

@@ -1,13 +1,89 @@
from flask import Blueprint, render_template, request, flash, redirect, url_for, jsonify
from flask import Blueprint, render_template, request, flash, redirect, url_for, jsonify, current_app
from flask_mail import Message
from flask_login import login_required, current_user
from functools import wraps
from datetime import datetime, timedelta
from sqlalchemy import func, desc
import secrets
import uuid
from app.routes.mail import mail
from app.extensions import db
from app.models import User, Post, PostImage, GPXFile, Comment, Like, PageView
from app.models import User, Post, PostImage, GPXFile, Comment, Like, PageView, MailSettings, SentEmail, PasswordResetRequest, PasswordResetToken, ChatRoom, ChatMessage
admin = Blueprint('admin', __name__, url_prefix='/admin')
def admin_required(f):
"""Decorator to require admin access"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or not current_user.is_admin:
flash('Admin access required.', 'error')
return redirect(url_for('auth.login'))
return f(*args, **kwargs)
return decorated_function
@admin.route('/mail-settings', methods=['GET', 'POST'])
@login_required
@admin_required
def mail_settings():
settings = MailSettings.query.first()
if request.method == 'POST':
enabled = bool(request.form.get('enabled'))
provider = request.form.get('provider')
server = request.form.get('server')
port = int(request.form.get('port') or 0)
use_tls = bool(request.form.get('use_tls'))
username = request.form.get('username')
password = request.form.get('password')
default_sender = request.form.get('default_sender')
if not settings:
settings = MailSettings()
db.session.add(settings)
settings.enabled = enabled
settings.provider = provider
settings.server = server
settings.port = port
settings.use_tls = use_tls
settings.username = username
settings.password = password
settings.default_sender = default_sender
db.session.commit()
flash('Mail settings updated.', 'success')
sent_emails = SentEmail.query.order_by(SentEmail.sent_at.desc()).limit(50).all()
return render_template('admin/mail_settings.html', settings=settings, sent_emails=sent_emails)
## Duplicate imports and Blueprint definitions removed
# Password reset token generator (simple, for demonstration)
def generate_reset_token(user):
# In production, use itsdangerous or Flask-Security for secure tokens
return secrets.token_urlsafe(32)
# Admin: Send password reset email to user
@admin.route('/users/<int:user_id>/reset-password', methods=['POST'])
@login_required
@admin_required
def reset_user_password(user_id):
user = User.query.get_or_404(user_id)
token = generate_reset_token(user)
# In production, save token to DB or cache, and validate on reset
reset_url = url_for('auth.reset_password', token=token, _external=True)
msg = Message(
subject="Password Reset Request",
recipients=[user.email],
body=f"Hello {user.nickname},\n\nAn admin has requested a password reset for your account. Click the link below to reset your password:\n{reset_url}\n\nIf you did not request this, please ignore this email."
)
try:
mail.send(msg)
flash(f"Password reset email sent to {user.email}.", "success")
except Exception as e:
current_app.logger.error(f"Error sending reset email: {e}")
flash(f"Failed to send password reset email: {e}", "danger")
return redirect(url_for('admin.user_detail', user_id=user.id))
def admin_required(f):
"""Decorator to require admin access"""
@wraps(f)
@@ -76,6 +152,22 @@ def dashboard():
.order_by(desc('view_count'))\
.limit(10).all()
# Password reset statistics
pending_password_requests = PasswordResetRequest.query.filter_by(status='pending').count()
active_reset_tokens = PasswordResetToken.query.filter_by(is_used=False).filter(
PasswordResetToken.expires_at > datetime.utcnow()
).count()
# Chat statistics
total_chat_rooms = ChatRoom.query.count()
linked_chat_rooms = ChatRoom.query.filter(ChatRoom.related_post_id.isnot(None)).count()
active_chat_rooms = db.session.query(ChatRoom.id).filter(
ChatRoom.messages.any(ChatMessage.created_at >= thirty_days_ago)
).distinct().count()
recent_chat_messages = ChatMessage.query.filter(
ChatMessage.created_at >= thirty_days_ago
).count()
return render_template('admin/dashboard.html',
total_users=total_users,
total_posts=total_posts,
@@ -90,7 +182,13 @@ def dashboard():
views_yesterday=views_yesterday,
views_this_week=views_this_week,
most_viewed_posts=most_viewed_posts,
most_viewed_pages=most_viewed_pages)
most_viewed_pages=most_viewed_pages,
pending_password_requests=pending_password_requests,
active_reset_tokens=active_reset_tokens,
total_chat_rooms=total_chat_rooms,
linked_chat_rooms=linked_chat_rooms,
active_chat_rooms=active_chat_rooms,
recent_chat_messages=recent_chat_messages)
@admin.route('/posts')
@login_required
@@ -98,7 +196,7 @@ def dashboard():
def posts():
"""Admin post management - review posts"""
page = request.args.get('page', 1, type=int)
status = request.args.get('status', 'pending') # pending, published, all
status = request.args.get('status', 'all') # pending, published, all
query = Post.query
@@ -126,26 +224,50 @@ def post_detail(post_id):
@login_required
@admin_required
def publish_post(post_id):
"""Publish a post"""
"""Publish a post and create map routes if GPX files exist"""
post = Post.query.get_or_404(post_id)
post.published = True
post.updated_at = datetime.utcnow()
db.session.commit()
flash(f'Post "{post.title}" has been published.', 'success')
try:
db.session.commit()
# Create map routes for GPX files when post is published
from app.utils.gpx_processor import process_post_approval
success = process_post_approval(post_id)
if success:
flash(f'Post "{post.title}" has been published and map routes created.', 'success')
else:
flash(f'Post "{post.title}" has been published. No GPX files found or error creating map routes.', 'warning')
except Exception as e:
db.session.rollback()
current_app.logger.error(f'Error publishing post {post_id}: {str(e)}')
flash(f'Error publishing post: {str(e)}', 'error')
return redirect(url_for('admin.posts'))
@admin.route('/posts/<int:post_id>/unpublish', methods=['POST'])
@login_required
@admin_required
def unpublish_post(post_id):
"""Unpublish a post"""
"""Unpublish a post and remove from map"""
post = Post.query.get_or_404(post_id)
post.published = False
post.updated_at = datetime.utcnow()
db.session.commit()
flash(f'Post "{post.title}" has been unpublished.', 'success')
try:
# Note: We keep the MapRoute data for potential re-publishing
# Only the API will filter by published status
db.session.commit()
flash(f'Post "{post.title}" has been unpublished.', 'success')
except Exception as e:
db.session.rollback()
current_app.logger.error(f'Error unpublishing post {post_id}: {str(e)}')
flash(f'Error unpublishing post: {str(e)}', 'error')
return redirect(url_for('admin.posts'))
@admin.route('/posts/<int:post_id>/delete', methods=['POST'])
@@ -153,14 +275,46 @@ def unpublish_post(post_id):
@admin_required
def delete_post(post_id):
"""Delete a post"""
current_app.logger.info(f'Admin {current_user.id} attempting to delete post {post_id}')
# Get all posts before deletion for debugging
all_posts_before = Post.query.all()
current_app.logger.info(f'Posts before deletion: {[p.id for p in all_posts_before]}')
post = Post.query.get_or_404(post_id)
title = post.title
# Delete associated files and records
db.session.delete(post)
db.session.commit()
current_app.logger.info(f'Found post to delete: ID={post.id}, Title="{title}"')
flash(f'Post "{title}" has been deleted.', 'success')
try:
# Delete associated map route if exists
from app.models import MapRoute
map_route = MapRoute.query.filter_by(post_id=post.id).first()
if map_route:
db.session.delete(map_route)
current_app.logger.info(f'Deleted MapRoute for post {post.id}')
# Delete associated files and records
db.session.delete(post)
db.session.commit()
# Clean up orphaned media folders
from app.utils.clean_orphan_media import clean_orphan_post_media
clean_orphan_post_media()
# Check posts after deletion
all_posts_after = Post.query.all()
current_app.logger.info(f'Posts after deletion: {[p.id for p in all_posts_after]}')
current_app.logger.info(f'Successfully deleted post {post_id}: "{title}"')
flash(f'Post "{title}" has been deleted.', 'success')
except Exception as e:
db.session.rollback()
current_app.logger.error(f'Error deleting post {post_id}: {str(e)}')
flash(f'Error deleting post: {str(e)}', 'error')
return redirect(url_for('admin.posts'))
@admin.route('/users')
@@ -375,3 +529,367 @@ def api_quick_stats():
'pending_posts': pending_count,
'today_views': today_views
})
# Password Reset Management Routes
@admin.route('/password-reset-requests')
@login_required
@admin_required
def password_reset_requests():
"""View all password reset requests"""
page = request.args.get('page', 1, type=int)
status = request.args.get('status', 'all')
query = PasswordResetRequest.query
if status != 'all':
query = query.filter_by(status=status)
requests = query.order_by(PasswordResetRequest.created_at.desc()).paginate(
page=page, per_page=20, error_out=False
)
return render_template('admin/password_reset_requests.html',
requests=requests, status=status)
@admin.route('/password-reset-requests/<int:request_id>')
@login_required
@admin_required
def password_reset_request_detail(request_id):
"""View individual password reset request details"""
reset_request = PasswordResetRequest.query.get_or_404(request_id)
# Get associated tokens
tokens = PasswordResetToken.query.filter_by(request_id=request_id).order_by(
PasswordResetToken.created_at.desc()
).all()
return render_template('admin/password_reset_request_detail.html',
request=reset_request, tokens=tokens)
@admin.route('/password-reset-requests/<int:request_id>/generate-token', methods=['POST'])
@login_required
@admin_required
def generate_password_reset_token(request_id):
"""Generate a new password reset token for a request"""
reset_request = PasswordResetRequest.query.get_or_404(request_id)
# Create token
token = str(uuid.uuid4())
expires_at = datetime.utcnow() + timedelta(hours=24) # 24 hour expiry
reset_token = PasswordResetToken(
token=token,
request_id=request_id,
user_id=reset_request.user.id,
created_by_admin_id=current_user.id,
expires_at=expires_at
)
db.session.add(reset_token)
reset_request.status = 'token_generated'
reset_request.updated_at = datetime.utcnow()
db.session.commit()
flash('Password reset token generated successfully!', 'success')
return redirect(url_for('admin.password_reset_token_template', token_id=reset_token.id))
@admin.route('/password-reset-tokens')
@login_required
@admin_required
def password_reset_tokens():
"""View all password reset tokens"""
page = request.args.get('page', 1, type=int)
status = request.args.get('status', 'all')
query = PasswordResetToken.query.join(User).order_by(PasswordResetToken.created_at.desc())
if status == 'active':
query = query.filter_by(is_used=False).filter(PasswordResetToken.expires_at > datetime.utcnow())
elif status == 'used':
query = query.filter_by(is_used=True)
elif status == 'expired':
query = query.filter(PasswordResetToken.expires_at <= datetime.utcnow(), PasswordResetToken.is_used == False)
tokens = query.paginate(page=page, per_page=20, error_out=False)
# Get counts for statistics
active_count = PasswordResetToken.query.filter_by(is_used=False).filter(
PasswordResetToken.expires_at > datetime.utcnow()
).count()
used_count = PasswordResetToken.query.filter_by(is_used=True).count()
expired_count = PasswordResetToken.query.filter(
PasswordResetToken.expires_at <= datetime.utcnow(),
PasswordResetToken.is_used == False
).count()
return render_template('admin/password_reset_tokens.html',
tokens=tokens, status=status,
active_count=active_count, used_count=used_count, expired_count=expired_count)
@admin.route('/manage-chats')
@login_required
@admin_required
def manage_chats():
"""Admin chat room management"""
page = request.args.get('page', 1, type=int)
category = request.args.get('category', '')
status = request.args.get('status', '')
search = request.args.get('search', '')
# Base query with message count
query = db.session.query(
ChatRoom,
func.count(ChatMessage.id).label('message_count'),
func.max(ChatMessage.created_at).label('last_activity')
).outerjoin(ChatMessage).group_by(ChatRoom.id)
# Apply filters
if category:
query = query.filter(ChatRoom.category == category)
if status == 'linked':
query = query.filter(ChatRoom.related_post_id.isnot(None))
elif status == 'unlinked':
query = query.filter(ChatRoom.related_post_id.is_(None))
elif status == 'active':
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
query = query.having(func.max(ChatMessage.created_at) >= thirty_days_ago)
elif status == 'inactive':
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
query = query.having(
db.or_(
func.max(ChatMessage.created_at) < thirty_days_ago,
func.max(ChatMessage.created_at).is_(None)
)
)
if search:
query = query.filter(
db.or_(
ChatRoom.name.contains(search),
ChatRoom.description.contains(search)
)
)
# Order by last activity
query = query.order_by(func.max(ChatMessage.created_at).desc().nullslast())
# Paginate
results = query.paginate(page=page, per_page=20, error_out=False)
# Process results to add message count and last activity to room objects
chat_rooms = []
for room, message_count, last_activity in results.items:
room.message_count = message_count
room.last_activity = last_activity
chat_rooms.append(room)
# Get statistics
total_rooms = ChatRoom.query.count()
linked_rooms = ChatRoom.query.filter(ChatRoom.related_post_id.isnot(None)).count()
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
active_today = db.session.query(ChatRoom.id).filter(
ChatRoom.messages.any(
func.date(ChatMessage.created_at) == datetime.utcnow().date()
)
).distinct().count()
total_messages = ChatMessage.query.count()
# Get available posts for linking
available_posts = Post.query.filter_by(published=True).order_by(Post.title).all()
# Create pagination object with processed rooms
class PaginationWrapper:
def __init__(self, original_pagination, items):
self.page = original_pagination.page
self.per_page = original_pagination.per_page
self.total = original_pagination.total
self.pages = original_pagination.pages
self.has_prev = original_pagination.has_prev
self.prev_num = original_pagination.prev_num
self.has_next = original_pagination.has_next
self.next_num = original_pagination.next_num
self.items = items
def iter_pages(self):
return range(1, self.pages + 1)
pagination = PaginationWrapper(results, chat_rooms)
return render_template('admin/manage_chats.html',
chat_rooms=chat_rooms,
pagination=pagination,
total_rooms=total_rooms,
linked_rooms=linked_rooms,
active_today=active_today,
total_messages=total_messages,
available_posts=available_posts)
@admin.route('/api/chat-rooms')
@login_required
@admin_required
def api_chat_rooms():
"""API endpoint for chat rooms (for AJAX calls)"""
exclude_id = request.args.get('exclude', type=int)
query = ChatRoom.query
if exclude_id:
query = query.filter(ChatRoom.id != exclude_id)
rooms = query.all()
# Get message counts
room_data = []
for room in rooms:
message_count = ChatMessage.query.filter_by(room_id=room.id).count()
room_data.append({
'id': room.id,
'name': room.name,
'description': room.description,
'category': room.category,
'message_count': message_count,
'created_at': room.created_at.isoformat() if room.created_at else None
})
return jsonify({'success': True, 'rooms': room_data})
@admin.route('/api/chat-rooms/<int:room_id>', methods=['PUT'])
@login_required
@admin_required
def api_update_chat_room(room_id):
"""Update chat room details"""
room = ChatRoom.query.get_or_404(room_id)
try:
data = request.get_json()
room.name = data.get('name', room.name)
room.description = data.get('description', room.description)
room.category = data.get('category', room.category)
# Handle post linking
related_post_id = data.get('related_post_id')
if related_post_id:
post = Post.query.get(related_post_id)
if post:
room.related_post_id = related_post_id
else:
return jsonify({'success': False, 'error': 'Post not found'})
else:
room.related_post_id = None
db.session.commit()
return jsonify({'success': True, 'message': 'Room updated successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)})
@admin.route('/api/chat-rooms/<int:room_id>/link-post', methods=['POST'])
@login_required
@admin_required
def api_link_chat_room_to_post(room_id):
"""Link chat room to a post"""
room = ChatRoom.query.get_or_404(room_id)
try:
data = request.get_json()
post_id = data.get('post_id')
if post_id:
post = Post.query.get_or_404(post_id)
room.related_post_id = post_id
else:
room.related_post_id = None
db.session.commit()
return jsonify({'success': True, 'message': 'Room linked to post successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)})
@admin.route('/api/chat-rooms/<int:source_room_id>/merge', methods=['POST'])
@login_required
@admin_required
def api_merge_chat_rooms(source_room_id):
"""Merge source room into target room"""
source_room = ChatRoom.query.get_or_404(source_room_id)
try:
data = request.get_json()
target_room_id = data.get('target_room_id')
target_room = ChatRoom.query.get_or_404(target_room_id)
# Move all messages from source to target room
messages = ChatMessage.query.filter_by(room_id=source_room_id).all()
for message in messages:
message.room_id = target_room_id
# Add system message about the merge
merge_message = ChatMessage(
room_id=target_room_id,
sender_id=current_user.id,
content=f"Room '{source_room.name}' has been merged into this room by admin {current_user.nickname}",
message_type='system',
is_system_message=True
)
db.session.add(merge_message)
# Delete the source room
db.session.delete(source_room)
db.session.commit()
return jsonify({'success': True, 'message': 'Rooms merged successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)})
@admin.route('/api/chat-rooms/<int:room_id>', methods=['DELETE'])
@login_required
@admin_required
def api_delete_chat_room(room_id):
"""Delete chat room and all its messages"""
room = ChatRoom.query.get_or_404(room_id)
try:
# Delete all messages first
ChatMessage.query.filter_by(room_id=room_id).delete()
# Delete the room
db.session.delete(room)
db.session.commit()
return jsonify({'success': True, 'message': 'Room deleted successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)})
@admin.route('/password-reset-tokens/<int:token_id>/template')
@login_required
@admin_required
def password_reset_token_template(token_id):
"""Display email template for password reset token"""
token = PasswordResetToken.query.get_or_404(token_id)
# Generate the reset URL
reset_url = url_for('auth.reset_password_with_token', token=token.token, _external=True)
return render_template('admin/password_reset_email_template.html',
token=token, reset_url=reset_url)

View File

@@ -1,12 +1,36 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask_login import login_user, logout_user, login_required, current_user
from werkzeug.security import check_password_hash
from app.models import User, db
from app.forms import LoginForm, RegisterForm, ForgotPasswordForm
from app.models import User, db, PasswordResetToken
from app.routes.reset_password import RequestResetForm, ResetPasswordForm
from flask_mail import Message
from app.routes.mail import mail
from app.utils.token import generate_reset_token, verify_reset_token
from datetime import datetime
import re
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, BooleanField, SubmitField
from wtforms.validators import DataRequired, Email, EqualTo, Length
auth = Blueprint('auth', __name__)
class LoginForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired()])
remember_me = BooleanField('Remember Me')
submit = SubmitField('Sign In')
class RegisterForm(FlaskForm):
nickname = StringField('Nickname', validators=[DataRequired(), Length(min=3, max=32)])
email = StringField('Email', validators=[DataRequired(), Email()])
password = PasswordField('Password', validators=[DataRequired(), Length(min=8)])
password2 = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Register')
class ForgotPasswordForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
submit = SubmitField('Request Password Reset')
@auth.route('/login', methods=['GET', 'POST'])
def login():
"""User login page"""
@@ -80,22 +104,108 @@ def logout():
@auth.route('/forgot-password', methods=['GET', 'POST'])
def forgot_password():
"""Forgot password page"""
"""Forgot password page - sends message to admin instead of email"""
if current_user.is_authenticated:
return redirect(url_for('main.index'))
form = ForgotPasswordForm()
form = RequestResetForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
# Create password reset user if it doesn't exist
reset_user = User.query.filter_by(email='reset_password@motoadventure.local').first()
if not reset_user:
reset_user = User(
nickname='PasswordReset',
email='reset_password@motoadventure.local',
is_active=False # This is a system user
)
reset_user.set_password('temp_password') # Won't be used
db.session.add(reset_user)
db.session.commit()
# Find admin support room
from app.models import ChatRoom, ChatMessage
admin_room = ChatRoom.query.filter_by(room_type='support').first()
if not admin_room:
# Create admin support room if it doesn't exist
system_user = User.query.filter_by(email='system@motoadventure.local').first()
admin_room = ChatRoom(
name='Technical Support',
description='Administrative support and password resets',
room_type='support',
is_private=False,
is_active=True,
created_by_id=system_user.id if system_user else 1
)
db.session.add(admin_room)
db.session.commit()
# Create the password reset message
if user:
# TODO: Implement email sending for password reset
flash('If an account with that email exists, we\'ve sent password reset instructions.', 'info')
message_content = f"A user with email '{user.email}' (nickname: {user.nickname}) needs their password to be changed. Please assist with password reset."
else:
flash('If an account with that email exists, we\'ve sent password reset instructions.', 'info')
message_content = f"Someone with email '{form.email.data}' requested a password reset, but no account exists with this email. Please check if this user needs assistance creating an account."
reset_message = ChatMessage(
content=message_content,
room_id=admin_room.id,
sender_id=reset_user.id,
is_system_message=True
)
db.session.add(reset_message)
# Update room activity
admin_room.last_activity = datetime.utcnow()
db.session.commit()
flash('Your password reset request has been sent to administrators. They will contact you soon to help reset your password.', 'info')
return redirect(url_for('auth.login'))
return render_template('auth/forgot_password.html', form=form)
@auth.route('/change-password', methods=['POST'])
@login_required
def change_password():
"""Change user password"""
current_password = request.form.get('current_password')
new_password = request.form.get('new_password')
confirm_password = request.form.get('confirm_password')
# Validate inputs
if not all([current_password, new_password, confirm_password]):
flash('All password fields are required.', 'error')
return redirect(url_for('community.profile'))
# Check current password
if not current_user.check_password(current_password):
flash('Current password is incorrect.', 'error')
return redirect(url_for('community.profile'))
# Validate new password
if len(new_password) < 6:
flash('New password must be at least 6 characters long.', 'error')
return redirect(url_for('community.profile'))
# Check password confirmation
if new_password != confirm_password:
flash('New password and confirmation do not match.', 'error')
return redirect(url_for('community.profile'))
# Check if new password is different from current
if current_user.check_password(new_password):
flash('New password must be different from your current password.', 'error')
return redirect(url_for('community.profile'))
try:
# Update password
current_user.set_password(new_password)
db.session.commit()
flash('Password updated successfully!', 'success')
except Exception as e:
db.session.rollback()
flash('An error occurred while updating your password. Please try again.', 'error')
return redirect(url_for('community.profile'))
def is_valid_password(password):
"""Validate password strength"""
if len(password) < 8:
@@ -105,3 +215,59 @@ def is_valid_password(password):
if not re.search(r'\d', password):
return False
return True
class ResetPasswordWithTokenForm(FlaskForm):
password = PasswordField('New Password', validators=[DataRequired(), Length(min=8)])
password2 = PasswordField('Confirm New Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Reset Password')
@auth.route('/reset-password/<token>', methods=['GET', 'POST'])
def reset_password_with_token(token):
"""Reset password using admin-generated token"""
# Find the token in database
reset_token = PasswordResetToken.query.filter_by(token=token).first()
if not reset_token:
flash('Invalid or expired reset link.', 'error')
return redirect(url_for('auth.login'))
# Check if token is expired
if reset_token.is_expired:
flash('This reset link has expired. Please request a new one.', 'error')
return redirect(url_for('auth.login'))
# Check if token is already used
if reset_token.is_used:
flash('This reset link has already been used.', 'error')
return redirect(url_for('auth.login'))
form = ResetPasswordWithTokenForm()
if form.validate_on_submit():
user = reset_token.user
# Validate password strength
if not is_valid_password(form.password.data):
flash('Password must be at least 8 characters long and contain both letters and numbers.', 'error')
return render_template('auth/reset_password_with_token.html', form=form, token=token)
# Update password
user.set_password(form.password.data)
# Mark token as used
reset_token.used_at = datetime.utcnow()
reset_token.user_ip = request.environ.get('REMOTE_ADDR')
# Update request status
if reset_token.request:
reset_token.request.status = 'completed'
reset_token.request.updated_at = datetime.utcnow()
db.session.commit()
flash('Your password has been reset successfully! You can now log in with your new password.', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/reset_password_with_token.html', form=form, token=token, user=reset_token.user)

276
app/routes/chat.py Normal file
View File

@@ -0,0 +1,276 @@
"""
Chat web interface routes
Provides HTML templates and endpoints for web-based chat
"""
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_login import login_required, current_user
from sqlalchemy import desc, and_
from datetime import datetime, timedelta
from app.extensions import db
from app.models import ChatRoom, ChatMessage, ChatParticipant, Post
# Create blueprint
chat = Blueprint('chat', __name__)
@chat.route('/')
@login_required
def index():
"""Chat main page with room list and rules"""
# Get user's recent chat rooms
user_rooms = db.session.query(ChatRoom).join(ChatParticipant).filter(
and_(
ChatParticipant.user_id == current_user.id,
ChatRoom.is_active == True
)
).order_by(desc(ChatRoom.last_activity)).limit(10).all()
# Get public rooms that are active
public_rooms = ChatRoom.query.filter(
ChatRoom.is_active == True,
ChatRoom.is_private == False
).order_by(desc(ChatRoom.last_activity)).limit(10).all()
return render_template('chat/index.html',
user_rooms=user_rooms,
public_rooms=public_rooms)
@chat.route('/room/<int:room_id>')
@login_required
def room(room_id):
"""Chat room interface"""
room = ChatRoom.query.get_or_404(room_id)
# Check access
if room.is_private:
participant = ChatParticipant.query.filter_by(
room_id=room_id,
user_id=current_user.id
).first()
if not participant:
flash('You do not have access to this chat room.', 'error')
return redirect(url_for('chat.index'))
# Get or create participant record
participant = ChatParticipant.query.filter_by(
room_id=room_id,
user_id=current_user.id
).first()
if not participant and not room.is_private:
# Auto-join public rooms
participant = ChatParticipant(
room_id=room_id,
user_id=current_user.id,
role='member'
)
db.session.add(participant)
db.session.commit()
# Get recent messages
messages = ChatMessage.query.filter_by(
room_id=room_id,
is_deleted=False
).order_by(ChatMessage.created_at).limit(50).all()
# Get participants
participants = ChatParticipant.query.filter_by(room_id=room_id).all()
return render_template('chat/room.html',
room=room,
messages=messages,
participants=participants,
current_participant=participant)
@chat.route('/create')
@login_required
def create_room_form():
"""Show create room form"""
# Get available posts for post discussions
recent_posts = Post.query.filter_by(published=True).order_by(
desc(Post.created_at)
).limit(20).all()
# Check if a specific post was requested
pre_selected_post = request.args.get('post_id')
if pre_selected_post:
try:
pre_selected_post = int(pre_selected_post)
except ValueError:
pre_selected_post = None
return render_template('chat/create_room.html', posts=recent_posts, pre_selected_post=pre_selected_post)
@chat.route('/create', methods=['POST'])
@login_required
def create_room():
"""Create a new chat room"""
room_name = request.form.get('room_name')
description = request.form.get('description', '')
room_type = request.form.get('room_type', 'general')
is_private = bool(request.form.get('is_private'))
related_post_id = request.form.get('related_post_id')
if not room_name:
flash('Room name is required.', 'error')
return redirect(url_for('chat.create_room_form'))
# Convert to integer if post ID is provided
if related_post_id:
try:
related_post_id = int(related_post_id)
# Verify the post exists
related_post = Post.query.get(related_post_id)
if not related_post:
flash('Selected post does not exist.', 'error')
return redirect(url_for('chat.create_room_form'))
# If post is selected, set room type to post_discussion
room_type = 'post_discussion'
except ValueError:
related_post_id = None
else:
related_post_id = None
# If no post selected, ensure it's general discussion
if room_type == 'post_discussion':
room_type = 'general'
# Check if room name already exists
existing_room = ChatRoom.query.filter_by(name=room_name).first()
if existing_room:
flash('A room with that name already exists.', 'error')
return redirect(url_for('chat.create_room_form'))
try:
# Create the room
room = ChatRoom(
name=room_name,
description=description,
room_type=room_type,
is_private=is_private,
is_active=True,
created_by_id=current_user.id,
related_post_id=related_post_id
)
db.session.add(room)
db.session.flush() # Get the room ID
# Add creator as participant with admin role
participant = ChatParticipant(
room_id=room.id,
user_id=current_user.id,
role='admin',
joined_at=datetime.utcnow()
)
db.session.add(participant)
# Add welcome message
if related_post_id:
welcome_content = f"Welcome to the discussion for '{related_post.title}'! This room was created by {current_user.nickname} to discuss this post."
else:
welcome_content = f"Welcome to {room_name}! This room was created by {current_user.nickname}."
welcome_message = ChatMessage(
content=welcome_content,
room_id=room.id,
sender_id=current_user.id,
is_system_message=True
)
db.session.add(welcome_message)
# Update room activity
room.last_activity = datetime.utcnow()
room.message_count = 1
db.session.commit()
if related_post_id:
flash(f'Chat room "{room_name}" created successfully and linked to the post!', 'success')
else:
flash(f'Chat room "{room_name}" created successfully!', 'success')
return redirect(url_for('chat.room', room_id=room.id))
except Exception as e:
db.session.rollback()
flash(f'Error creating room: {str(e)}', 'error')
return redirect(url_for('chat.create_room_form'))
@chat.route('/support')
@login_required
def support():
"""Admin support page"""
# Get user's recent support tickets (rooms they created for support)
recent_tickets = ChatRoom.query.filter(
ChatRoom.room_type == 'admin_support',
ChatRoom.created_by_id == current_user.id
).order_by(desc(ChatRoom.created_at)).limit(5).all()
return render_template('chat/support.html',
recent_tickets=recent_tickets)
@chat.route('/embed/<int:post_id>')
@login_required
def embed_post_chat(post_id):
"""Embedded chat widget for post pages"""
post = Post.query.get_or_404(post_id)
# Find existing discussion room
discussion_room = ChatRoom.query.filter(
and_(
ChatRoom.room_type == 'post_discussion',
ChatRoom.related_post_id == post_id,
ChatRoom.is_active == True
)
).first()
return render_template('chat/embed.html',
post=post,
discussion_room=discussion_room)
@chat.route('/post-discussions')
@login_required
def post_discussions():
"""View all chat rooms related to posts"""
page = request.args.get('page', 1, type=int)
# Get all rooms that are linked to posts
post_rooms = ChatRoom.query.filter(
ChatRoom.related_post_id.isnot(None),
ChatRoom.is_active == True
).join(Post).order_by(ChatRoom.last_activity.desc()).paginate(
page=page, per_page=20, error_out=False
)
# Get statistics
total_post_discussions = ChatRoom.query.filter(
ChatRoom.related_post_id.isnot(None),
ChatRoom.is_active == True
).count()
active_discussions = ChatRoom.query.filter(
ChatRoom.related_post_id.isnot(None),
ChatRoom.is_active == True,
ChatRoom.last_activity >= datetime.utcnow() - timedelta(days=7)
).count()
return render_template('chat/post_discussions.html',
rooms=post_rooms,
total_discussions=total_post_discussions,
active_discussions=active_discussions)
@chat.route('/post/<int:post_id>/discussions')
@login_required
def post_specific_discussions(post_id):
"""View all chat rooms for a specific post"""
post = Post.query.get_or_404(post_id)
# Get all rooms for this specific post
rooms = ChatRoom.query.filter(
ChatRoom.related_post_id == post_id,
ChatRoom.is_active == True
).order_by(ChatRoom.last_activity.desc()).all()
return render_template('chat/post_specific_discussions.html',
post=post, rooms=rooms)

560
app/routes/chat_api.py Normal file
View File

@@ -0,0 +1,560 @@
"""
Chat API routes for mobile app compatibility
Provides RESTful endpoints for chat functionality
"""
from flask import Blueprint, request, jsonify, current_app
from flask_login import login_required, current_user
from sqlalchemy import desc, and_, or_
from datetime import datetime, timedelta
import re
from app.extensions import db
from app.models import ChatRoom, ChatMessage, ChatParticipant, Post, User
# Create blueprint
chat_api = Blueprint('chat_api', __name__)
# Chat rules and guidelines
CHAT_RULES = [
"Be respectful and courteous to all community members",
"No offensive language, harassment, or personal attacks",
"Stay on topic - use post-specific chats for discussions about routes",
"No spam or promotional content without permission",
"Share useful tips and experiences about motorcycle adventures",
"Help newcomers and answer questions when you can",
"Report inappropriate behavior to administrators",
"Keep conversations constructive and helpful"
]
# Profanity filter (basic implementation)
BLOCKED_WORDS = [
'spam', 'scam', 'fake', 'stupid', 'idiot', 'hate'
# Add more words as needed
]
def contains_blocked_content(text):
"""Check if text contains blocked words"""
text_lower = text.lower()
return any(word in text_lower for word in BLOCKED_WORDS)
@chat_api.route('/rules', methods=['GET'])
def get_chat_rules():
"""Get chat rules and guidelines"""
return jsonify({
'success': True,
'rules': CHAT_RULES
})
@chat_api.route('/rooms', methods=['GET'])
@login_required
def get_chat_rooms():
"""Get list of available chat rooms for the user"""
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
room_type = request.args.get('type', None)
# Base query - only active rooms the user can access
query = ChatRoom.query.filter(ChatRoom.is_active == True)
# Filter by type if specified
if room_type:
query = query.filter(ChatRoom.room_type == room_type)
# Order by last activity
query = query.order_by(desc(ChatRoom.last_activity))
# Paginate
rooms = query.paginate(
page=page, per_page=per_page, error_out=False
)
return jsonify({
'success': True,
'rooms': [room.to_dict() for room in rooms.items],
'pagination': {
'page': page,
'pages': rooms.pages,
'per_page': per_page,
'total': rooms.total,
'has_next': rooms.has_next,
'has_prev': rooms.has_prev
}
})
@chat_api.route('/rooms', methods=['POST'])
@login_required
def create_chat_room():
"""Create a new chat room"""
data = request.get_json()
if not data or not data.get('name'):
return jsonify({
'success': False,
'error': 'Room name is required'
}), 400
# Validate input
name = data.get('name', '').strip()
description = data.get('description', '').strip()
room_type = data.get('room_type', 'general')
related_post_id = data.get('related_post_id')
if len(name) < 3 or len(name) > 100:
return jsonify({
'success': False,
'error': 'Room name must be between 3 and 100 characters'
}), 400
# Check if room already exists
existing_room = ChatRoom.query.filter_by(name=name).first()
if existing_room:
return jsonify({
'success': False,
'error': 'A room with this name already exists'
}), 400
# Validate related post if specified
if related_post_id:
post = Post.query.get(related_post_id)
if not post:
return jsonify({
'success': False,
'error': 'Related post not found'
}), 404
try:
# Create room
room = ChatRoom(
name=name,
description=description,
room_type=room_type,
created_by_id=current_user.id,
related_post_id=related_post_id
)
db.session.add(room)
db.session.flush() # Get the room ID
# Add creator as participant with admin role
participant = ChatParticipant(
room_id=room.id,
user_id=current_user.id,
role='admin'
)
db.session.add(participant)
# Add system message
system_message = ChatMessage(
room_id=room.id,
user_id=current_user.id,
content=f"Welcome to {name}! This chat room was created for motorcycle adventure discussions.",
message_type='system'
)
db.session.add(system_message)
db.session.commit()
return jsonify({
'success': True,
'room': room.to_dict()
}), 201
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error creating chat room: {str(e)}")
return jsonify({
'success': False,
'error': 'Failed to create chat room'
}), 500
@chat_api.route('/rooms/<int:room_id>', methods=['GET'])
@login_required
def get_chat_room(room_id):
"""Get chat room details"""
room = ChatRoom.query.get_or_404(room_id)
# Check if user has access
if room.is_private:
participant = ChatParticipant.query.filter_by(
room_id=room_id,
user_id=current_user.id
).first()
if not participant:
return jsonify({
'success': False,
'error': 'Access denied'
}), 403
return jsonify({
'success': True,
'room': room.to_dict()
})
@chat_api.route('/rooms/<int:room_id>/join', methods=['POST'])
@login_required
def join_chat_room(room_id):
"""Join a chat room"""
room = ChatRoom.query.get_or_404(room_id)
# Check if already a participant
existing_participant = ChatParticipant.query.filter_by(
room_id=room_id,
user_id=current_user.id
).first()
if existing_participant:
return jsonify({
'success': True,
'message': 'Already a member of this room'
})
try:
# Add user as participant
participant = ChatParticipant(
room_id=room_id,
user_id=current_user.id,
role='member'
)
db.session.add(participant)
# Add system message
system_message = ChatMessage(
room_id=room_id,
user_id=current_user.id,
content=f"{current_user.nickname} joined the chat",
message_type='system'
)
db.session.add(system_message)
# Update room activity
room.last_activity = datetime.utcnow()
db.session.commit()
return jsonify({
'success': True,
'message': 'Successfully joined the room'
})
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error joining chat room: {str(e)}")
return jsonify({
'success': False,
'error': 'Failed to join room'
}), 500
@chat_api.route('/rooms/<int:room_id>/messages', methods=['GET'])
@login_required
def get_chat_messages(room_id):
"""Get messages from a chat room"""
room = ChatRoom.query.get_or_404(room_id)
# Check access
if room.is_private:
participant = ChatParticipant.query.filter_by(
room_id=room_id,
user_id=current_user.id
).first()
if not participant:
return jsonify({
'success': False,
'error': 'Access denied'
}), 403
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int)
# Get messages (newest first for mobile scrolling)
messages = ChatMessage.query.filter_by(
room_id=room_id,
is_deleted=False
).order_by(desc(ChatMessage.created_at)).paginate(
page=page, per_page=per_page, error_out=False
)
# Update user's last seen
participant = ChatParticipant.query.filter_by(
room_id=room_id,
user_id=current_user.id
).first()
if participant:
participant.last_seen = datetime.utcnow()
db.session.commit()
return jsonify({
'success': True,
'messages': [msg.to_dict() for msg in reversed(messages.items)],
'pagination': {
'page': page,
'pages': messages.pages,
'per_page': per_page,
'total': messages.total,
'has_next': messages.has_next,
'has_prev': messages.has_prev
}
})
@chat_api.route('/rooms/<int:room_id>/messages', methods=['POST'])
@login_required
def send_message(room_id):
"""Send a message to a chat room"""
room = ChatRoom.query.get_or_404(room_id)
data = request.get_json()
if not data or not data.get('content'):
return jsonify({
'success': False,
'error': 'Message content is required'
}), 400
content = data.get('content', '').strip()
message_type = data.get('message_type', 'text')
reply_to_id = data.get('reply_to_id')
# Validate content
if len(content) < 1 or len(content) > 2000:
return jsonify({
'success': False,
'error': 'Message must be between 1 and 2000 characters'
}), 400
# Check for blocked content
if contains_blocked_content(content):
return jsonify({
'success': False,
'error': 'Message contains inappropriate content'
}), 400
# Check if user is participant
participant = ChatParticipant.query.filter_by(
room_id=room_id,
user_id=current_user.id
).first()
if not participant:
return jsonify({
'success': False,
'error': 'You must join the room first'
}), 403
if participant.is_muted:
return jsonify({
'success': False,
'error': 'You are muted in this room'
}), 403
try:
# Create message
message = ChatMessage(
room_id=room_id,
user_id=current_user.id,
content=content,
message_type=message_type,
reply_to_id=reply_to_id
)
db.session.add(message)
# Update room activity
room.last_activity = datetime.utcnow()
# Update participant last seen
participant.last_seen = datetime.utcnow()
db.session.commit()
return jsonify({
'success': True,
'message': message.to_dict()
}), 201
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error sending message: {str(e)}")
return jsonify({
'success': False,
'error': 'Failed to send message'
}), 500
@chat_api.route('/rooms/<int:room_id>/participants', methods=['GET'])
@login_required
def get_room_participants(room_id):
"""Get participants of a chat room"""
room = ChatRoom.query.get_or_404(room_id)
participants = ChatParticipant.query.filter_by(room_id=room_id).all()
return jsonify({
'success': True,
'participants': [p.to_dict() for p in participants]
})
@chat_api.route('/admin-support', methods=['POST'])
@login_required
def create_admin_support_chat():
"""Create a chat room for admin support (e.g., password reset)"""
data = request.get_json()
reason = data.get('reason', 'general_support')
description = data.get('description', '')
# Check if user already has an active admin support chat
existing_room = ChatRoom.query.filter(
and_(
ChatRoom.room_type == 'admin_support',
ChatRoom.created_by_id == current_user.id,
ChatRoom.is_active == True
)
).first()
if existing_room:
return jsonify({
'success': True,
'room': existing_room.to_dict(),
'message': 'Using existing support chat'
})
try:
# Create admin support room
room_name = f"Support - {current_user.nickname} - {reason}"
room = ChatRoom(
name=room_name,
description=f"Admin support chat for {current_user.nickname}. Reason: {reason}",
room_type='admin_support',
is_private=True,
created_by_id=current_user.id
)
db.session.add(room)
db.session.flush()
# Add user as participant
participant = ChatParticipant(
room_id=room.id,
user_id=current_user.id,
role='member'
)
db.session.add(participant)
# Add all admins as participants
admins = User.query.filter_by(is_admin=True).all()
for admin in admins:
if admin.id != current_user.id:
admin_participant = ChatParticipant(
room_id=room.id,
user_id=admin.id,
role='admin'
)
db.session.add(admin_participant)
# Add initial message
initial_message = ChatMessage(
room_id=room.id,
user_id=current_user.id,
content=f"Hello! I need help with: {reason}. {description}",
message_type='text'
)
db.session.add(initial_message)
db.session.commit()
return jsonify({
'success': True,
'room': room.to_dict()
}), 201
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error creating admin support chat: {str(e)}")
return jsonify({
'success': False,
'error': 'Failed to create support chat'
}), 500
@chat_api.route('/post/<int:post_id>/discussion', methods=['POST'])
@login_required
def create_post_discussion(post_id):
"""Create or get discussion chat for a specific post"""
post = Post.query.get_or_404(post_id)
# Check if discussion already exists
existing_room = ChatRoom.query.filter(
and_(
ChatRoom.room_type == 'post_discussion',
ChatRoom.related_post_id == post_id,
ChatRoom.is_active == True
)
).first()
if existing_room:
# Join the existing room if not already a participant
participant = ChatParticipant.query.filter_by(
room_id=existing_room.id,
user_id=current_user.id
).first()
if not participant:
participant = ChatParticipant(
room_id=existing_room.id,
user_id=current_user.id,
role='member'
)
db.session.add(participant)
db.session.commit()
return jsonify({
'success': True,
'room': existing_room.to_dict(),
'message': 'Joined existing discussion'
})
try:
# Create new discussion room
room_name = f"Discussion: {post.title}"
room = ChatRoom(
name=room_name,
description=f"Discussion about the post: {post.title}",
room_type='post_discussion',
created_by_id=current_user.id,
related_post_id=post_id
)
db.session.add(room)
db.session.flush()
# Add creator as participant
participant = ChatParticipant(
room_id=room.id,
user_id=current_user.id,
role='moderator'
)
db.session.add(participant)
# Add post author as participant if different
if post.author_id != current_user.id:
author_participant = ChatParticipant(
room_id=room.id,
user_id=post.author_id,
role='moderator'
)
db.session.add(author_participant)
# Add initial message
initial_message = ChatMessage(
room_id=room.id,
user_id=current_user.id,
content=f"Started discussion about: {post.title}",
message_type='system'
)
db.session.add(initial_message)
db.session.commit()
return jsonify({
'success': True,
'room': room.to_dict()
}), 201
except Exception as e:
db.session.rollback()
current_app.logger.error(f"Error creating post discussion: {str(e)}")
return jsonify({
'success': False,
'error': 'Failed to create discussion'
}), 500

View File

@@ -28,6 +28,11 @@ def index():
return render_template('community/index.html', posts=posts, posts_with_routes=posts_with_routes)
@community.route('/test-map')
def test_map():
"""Test map page for debugging"""
return render_template('community/test_map.html')
@community.route('/post/<int:id>')
def post_detail(id):
"""Individual post detail page"""
@@ -273,8 +278,8 @@ def new_post():
original_name=cover_file.filename,
size=result['size'],
mime_type=result['mime_type'],
post_id=post.id
# Note: is_cover column missing, will be added in future migration
post_id=post.id,
is_cover=True # Mark as cover image
)
db.session.add(cover_image)
current_app.logger.info(f'Cover image saved: {result["filename"]}')
@@ -296,10 +301,45 @@ def new_post():
post_id=post.id
)
db.session.add(gpx_file_record)
db.session.flush() # Get the GPX file ID
# Extract GPX statistics
from app.utils.gpx_processor import process_gpx_file
if process_gpx_file(gpx_file_record):
current_app.logger.info(f'GPX statistics extracted for: {result["filename"]}')
else:
current_app.logger.warning(f'Failed to extract GPX statistics for: {result["filename"]}')
current_app.logger.info(f'GPX file saved: {result["filename"]}')
except Exception as e:
current_app.logger.warning(f'Error processing GPX file: {str(e)}')
# Handle section images (multiple images from content sections)
section_images_processed = 0
for key in request.files.keys():
if key.startswith('section_image_') and not key.endswith('_section'):
try:
image_file = request.files[key]
if image_file and image_file.filename:
current_app.logger.info(f'Processing section image: {image_file.filename}')
result = save_image(image_file, post.id)
if result['success']:
section_image = PostImage(
filename=result['filename'],
original_name=image_file.filename,
size=result['size'],
mime_type=result['mime_type'],
post_id=post.id,
is_cover=False # Section images are not cover images
)
db.session.add(section_image)
section_images_processed += 1
current_app.logger.info(f'Section image saved: {result["filename"]}')
except Exception as e:
current_app.logger.warning(f'Error processing section image {key}: {str(e)}')
current_app.logger.info(f'Total section images processed: {section_images_processed}')
db.session.commit()
current_app.logger.info(f'Post {post.id} committed to database successfully')
@@ -631,47 +671,68 @@ def save_gpx_file(gpx_file, post_id):
@community.route('/api/routes')
def api_routes():
"""API endpoint to get all routes for map display"""
posts_with_routes = Post.query.filter_by(published=True).join(GPXFile).all()
routes_data = []
for post in posts_with_routes:
for gpx_file in post.gpx_files:
"""API endpoint to get all routes for map display - database optimized"""
try:
from app.utils.gpx_processor import get_all_map_routes
routes_data = get_all_map_routes()
# Add additional post information and format for frontend
formatted_routes = []
for route_data in routes_data:
try:
# Read and parse GPX file using new folder structure
if post.media_folder:
gpx_path = os.path.join(current_app.root_path, 'static', 'media', 'posts',
post.media_folder, 'gpx', gpx_file.filename)
else:
# Fallback to old path for existing files
gpx_path = os.path.join(current_app.instance_path, 'uploads', 'gpx', gpx_file.filename)
if os.path.exists(gpx_path):
with open(gpx_path, 'r') as f:
gpx_content = f.read()
gpx = gpxpy.parse(gpx_content)
# Extract coordinates
coordinates = []
for track in gpx.tracks:
for segment in track.segments:
for point in segment.points:
coordinates.append([point.latitude, point.longitude])
if coordinates:
routes_data.append({
'id': post.id,
'title': post.title,
'author': post.author.nickname,
'coordinates': coordinates,
'url': url_for('community.post_detail', id=post.id)
})
formatted_routes.append({
'id': route_data['post_id'],
'title': route_data['post_title'],
'author': route_data['post_author'],
'coordinates': route_data['coordinates'],
'url': url_for('community.post_detail', id=route_data['post_id']),
'distance': route_data['stats']['distance'],
'elevation_gain': route_data['stats']['elevation_gain'],
'max_elevation': route_data['stats']['max_elevation'],
'total_points': len(route_data['coordinates']),
'start_point': route_data['start_point'],
'end_point': route_data['end_point'],
'bounds': route_data['bounds']
})
except Exception as e:
current_app.logger.error(f'Error processing GPX file {gpx_file.filename}: {str(e)}')
current_app.logger.error(f'Error formatting route data: {str(e)}')
continue
return jsonify(routes_data)
return jsonify(formatted_routes)
except Exception as e:
current_app.logger.error(f'Error getting map routes: {str(e)}')
return jsonify([]) # Return empty array on error
@community.route('/api/route/<int:post_id>')
def api_route_detail(post_id):
"""API endpoint to get detailed route data for a specific post"""
try:
from app.utils.gpx_processor import get_post_route_details
route_data = get_post_route_details(post_id)
if not route_data:
return jsonify({'error': 'Route not found'}), 404
# Format for frontend
formatted_route = {
'id': route_data['post_id'],
'coordinates': route_data['coordinates'],
'simplified_coordinates': route_data['simplified_coordinates'],
'start_point': route_data['start_point'],
'end_point': route_data['end_point'],
'bounds': route_data['bounds'],
'stats': route_data['stats']
}
return jsonify(formatted_route)
except Exception as e:
current_app.logger.error(f'Error getting route details for post {post_id}: {str(e)}')
return jsonify({'error': 'Internal server error'}), 500
@community.route('/media/posts/<post_folder>/images/<filename>')
def serve_image(post_folder, filename):

2
app/routes/mail.py Normal file
View File

@@ -0,0 +1,2 @@
from flask_mail import Mail
mail = Mail()

View File

@@ -24,6 +24,18 @@ def health_check():
"""Health check endpoint"""
return {'status': 'healthy', 'timestamp': datetime.utcnow().isoformat()}
@main.route('/map-test')
def map_test():
"""Serve the map test page"""
from flask import send_from_directory
return send_from_directory('static', 'map_test.html')
@main.route('/basic-map-test')
def basic_map_test():
"""Serve the basic map test page"""
from flask import send_from_directory
return send_from_directory('static', 'basic_map_test.html')
@main.app_errorhandler(404)
def not_found_error(error):
return render_template('errors/404.html'), 404

View File

@@ -0,0 +1,12 @@
from flask_wtf import FlaskForm
from wtforms import StringField, PasswordField, SubmitField
from wtforms.validators import DataRequired, Email, EqualTo, Length
class RequestResetForm(FlaskForm):
email = StringField('Email', validators=[DataRequired(), Email()])
submit = SubmitField('Request Password Reset')
class ResetPasswordForm(FlaskForm):
password = PasswordField('New Password', validators=[DataRequired(), Length(min=6)])
confirm_password = PasswordField('Confirm Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Reset Password')

363
app/static/map_iframe.html Normal file
View File

@@ -0,0 +1,363 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Adventure Routes Map</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
overflow: hidden;
}
#map {
height: 100vh;
width: 100vw;
}
.map-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1000;
background: rgba(255, 255, 255, 0.95);
padding: 20px;
border-radius: 8px;
text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #f97316;
border-radius: 50%;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
margin: 0 auto 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.route-popup {
min-width: 250px;
font-family: inherit;
}
.route-popup h3 {
margin: 0 0 10px 0;
color: #1f2937;
font-size: 1.1em;
font-weight: 600;
}
.route-popup .stat {
display: flex;
justify-content: space-between;
margin: 5px 0;
padding: 2px 0;
border-bottom: 1px solid #e5e7eb;
}
.route-popup .stat:last-of-type {
border-bottom: none;
}
.route-popup .stat strong {
color: #374151;
}
.route-popup .view-btn {
display: inline-block;
margin-top: 10px;
padding: 8px 16px;
background: linear-gradient(45deg, #f97316, #ea580c);
color: white;
text-decoration: none;
border-radius: 6px;
font-size: 0.9em;
transition: all 0.2s;
}
.route-popup .view-btn:hover {
background: linear-gradient(45deg, #ea580c, #dc2626);
transform: translateY(-1px);
}
.map-controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 5px;
}
.map-control-btn {
background: white;
border: 2px solid #ddd;
border-radius: 6px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 16px;
transition: all 0.2s;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.map-control-btn:hover {
background: #f3f4f6;
transform: scale(1.05);
}
.map-control-btn.active {
background: #f97316;
color: white;
border-color: #ea580c;
}
</style>
</head>
<body>
<div id="map">
<div id="map-loading" class="map-loading">
<div class="spinner"></div>
<div>Loading adventure routes...</div>
</div>
</div>
<!-- Map Controls -->
<div class="map-controls">
<button id="refresh-map" class="map-control-btn" title="Refresh map">🔄</button>
</div>
<script>
let map;
let routeLayer;
let routesVisible = true;
function initializeMap() {
console.log('Initializing standalone map...');
// Romania center and zoom for default view
const romaniaCenter = [45.9432, 24.9668]; // Center of Romania
const romaniaZoom = 7;
// Create map
map = L.map('map', {
zoomControl: true,
scrollWheelZoom: false, // Disable default scroll wheel zoom
doubleClickZoom: true,
touchZoom: true
}).setView(romaniaCenter, romaniaZoom);
// Enable zoom with Ctrl/Cmd + wheel only
map.getContainer().addEventListener('wheel', function(e) {
if ((e.ctrlKey || e.metaKey) && map.options.scrollWheelZoom !== true) {
map.options.scrollWheelZoom = true;
} else if (!(e.ctrlKey || e.metaKey) && map.options.scrollWheelZoom !== false) {
map.options.scrollWheelZoom = false;
}
if (!(e.ctrlKey || e.metaKey)) {
e.preventDefault(); // Prevent zoom if not holding Ctrl/Cmd
}
}, { passive: false });
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 18,
minZoom: 5
}).addTo(map);
console.log('Base map created');
// Create route layer group
routeLayer = L.layerGroup().addTo(map);
// Load routes
loadRoutes();
// Setup controls
setupControls();
}
function loadRoutes() {
console.log('Loading routes from API...');
// Get the parent window's origin for API calls
const apiUrl = window.location.origin + '/community/api/routes';
fetch(apiUrl)
.then(response => {
console.log('API response status:', response.status);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(routesData => {
console.log(`Loaded ${routesData.length} routes`);
// Hide loading indicator
const loading = document.getElementById('map-loading');
if (loading) {
loading.style.display = 'none';
}
if (routesData.length === 0) {
console.log('No routes found');
return;
}
// Route colors
const colors = [
'#f97316', '#dc2626', '#059669', '#7c3aed',
'#db2777', '#2563eb', '#7c2d12', '#065f46'
];
const allBounds = [];
// Add each route
routesData.forEach((route, index) => {
if (route.coordinates && route.coordinates.length > 0) {
const color = colors[index % colors.length];
// Create polyline
const polyline = L.polyline(route.coordinates, {
color: color,
weight: 4,
opacity: 0.8,
smoothFactor: 1
});
// Create popup content
const popupContent = `
<div class="route-popup">
<h3>🏍️ ${route.title}</h3>
<div class="stat">
<span>👤 Author:</span>
<strong>${route.author}</strong>
</div>
<div class="stat">
<span>📏 Distance:</span>
<strong>${route.distance.toFixed(2)} km</strong>
</div>
<div class="stat">
<span>⛰️ Elevation Gain:</span>
<strong>${route.elevation_gain.toFixed(0)} m</strong>
</div>
<div class="stat">
<span>🏔️ Max Elevation:</span>
<strong>${route.max_elevation.toFixed(0)} m</strong>
</div>
<a href="${route.url}" target="_parent" class="view-btn">
🔍 View Adventure Details
</a>
</div>
`;
polyline.bindPopup(popupContent);
routeLayer.addLayer(polyline);
// Add start and end markers
const startCoord = route.coordinates[0];
const endCoord = route.coordinates[route.coordinates.length - 1];
// Green start marker
const startMarker = L.marker(startCoord, {
icon: L.icon({
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
shadowSize: [41, 41]
})
}).bindPopup(`<b>Start of route</b><br>${route.title}`);
routeLayer.addLayer(startMarker);
// Red end marker
const endMarker = L.marker(endCoord, {
icon: L.icon({
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
shadowSize: [41, 41]
})
}).bindPopup(`<b>End of route</b><br>${route.title}`);
routeLayer.addLayer(endMarker);
// Collect bounds for fitting
allBounds.push(...route.coordinates);
console.log(`Added route: ${route.title} (${route.coordinates.length} points)`);
}
});
console.log(`Successfully loaded ${routesData.length} routes`);
// Do NOT auto-fit map to routes on load; keep Romania view/zoom
// Only fit to routes when user clicks the 🎯 button
// (see setupControls)
})
.catch(error => {
console.error('Error loading routes:', error);
const loading = document.getElementById('map-loading');
if (loading) {
loading.innerHTML = `
<div style="color: #dc2626;">
<div style="font-size: 1.2em; margin-bottom: 8px;">⚠️</div>
<div>Failed to load routes</div>
<div style="font-size: 0.8em; margin-top: 5px; color: #6b7280;">
${error.message}
</div>
</div>
`;
}
});
}
function setupControls() {
// Only refresh map button remains
// Refresh map button
document.getElementById('refresh-map').addEventListener('click', () => {
routeLayer.clearLayers();
loadRoutes();
});
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeMap);
} else {
initializeMap();
}
// Handle resize
window.addEventListener('resize', () => {
if (map) {
setTimeout(() => map.invalidateSize(), 100);
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,98 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Route Map (Debug)</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin=""/>
<style>
html, body { height: 100%; margin: 0; padding: 0; }
#map { width: 100vw; height: 100vh; min-height: 100%; min-width: 100%; border-radius: 1rem; }
.leaflet-container { background: #f8f9fa; }
</style>
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script>
// Get route_id from query string
function getRouteId() {
const params = new URLSearchParams(window.location.search);
return params.get('route_id');
}
const routeId = getRouteId();
const map = L.map('map');
// Set map size to fit iframe
function resizeMap() {
const mapDiv = document.getElementById('map');
mapDiv.style.width = window.innerWidth + 'px';
mapDiv.style.height = window.innerHeight + 'px';
map.invalidateSize();
}
window.addEventListener('resize', resizeMap);
// Initial size
resizeMap();
// Add OSM tiles
// Use CartoDB Positron for English map labels (clean, readable, English by default)
L.tileLayer('https://{s}.basemaps.cartocdn.com/light_all/{z}/{x}/{y}{r}.png', {
attribution: '© OpenStreetMap contributors & CartoDB',
subdomains: 'abcd',
maxZoom: 19
}).addTo(map);
// Ensure map is always north-up (default in Leaflet)
// Prevent any future rotation plugins or gestures
map.dragRotate && map.dragRotate.disable && map.dragRotate.disable();
map.touchZoomRotate && map.touchZoomRotate.disableRotation && map.touchZoomRotate.disableRotation();
// Fetch route data
if (routeId) {
fetch(`/community/api/route/${routeId}`)
.then(response => response.json())
.then(data => {
if (data && data.coordinates && data.coordinates.length > 0) {
const latlngs = data.coordinates.map(pt => [pt[0], pt[1]]);
const polyline = L.polyline(latlngs, { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(map);
map.fitBounds(polyline.getBounds(), { padding: [20, 20], maxZoom: 15 });
// Start marker (green pin)
L.marker(latlngs[0], {
icon: L.icon({
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-green.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
shadowSize: [41, 41]
})
}).addTo(map);
// End marker (red pin)
L.marker(latlngs[latlngs.length-1], {
icon: L.icon({
iconUrl: 'https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-2x-red.png',
iconSize: [25, 41],
iconAnchor: [12, 41],
popupAnchor: [1, -34],
shadowUrl: 'https://unpkg.com/leaflet@1.9.4/dist/images/marker-shadow.png',
shadowSize: [41, 41]
})
}).addTo(map);
} else {
map.setView([45.9432, 24.9668], 6);
L.marker([45.9432, 24.9668]).addTo(map).bindPopup('No route data').openPopup();
}
})
.catch(err => {
map.setView([45.9432, 24.9668], 6);
L.marker([45.9432, 24.9668]).addTo(map).bindPopup('Error loading route').openPopup();
});
} else {
map.setView([45.9432, 24.9668], 6);
L.marker([45.9432, 24.9668]).addTo(map).bindPopup('No route selected').openPopup();
}
</script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

View File

@@ -1,685 +0,0 @@
<?xml version="1.0" encoding="utf-8" standalone="yes"?>
<gpx version="1.1" creator="GPS Visualizer https://www.gpsvisualizer.com/" xmlns="http://www.topografix.com/GPX/1/1" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://www.topografix.com/GPX/1/1 http://www.topografix.com/GPX/1/1/gpx.xsd">
<wpt lat="46.3585354" lon="25.5451964">
<name>Strada Harghita 42, Vlăhița 535800, Romania</name>
<sym>https://www.gstatic.com/mapspro/images/stock/503-wht-blank_maps.png</sym>
</wpt>
<wpt lat="46.4412431" lon="25.5800642">
<name>Unnamed Road, Romania</name>
<sym>https://www.gstatic.com/mapspro/images/stock/503-wht-blank_maps.png</sym>
</wpt>
<trk>
<name>OFF26</name>
<trkseg>
<trkpt lat="46.35854" lon="25.5452"></trkpt>
<trkpt lat="46.35873" lon="25.5452"></trkpt>
<trkpt lat="46.35899" lon="25.54524"></trkpt>
<trkpt lat="46.35934" lon="25.54526"></trkpt>
<trkpt lat="46.36006" lon="25.54549"></trkpt>
<trkpt lat="46.36011" lon="25.54549"></trkpt>
<trkpt lat="46.36017" lon="25.54548"></trkpt>
<trkpt lat="46.36031" lon="25.5454"></trkpt>
<trkpt lat="46.36037" lon="25.54535"></trkpt>
<trkpt lat="46.36049" lon="25.5453"></trkpt>
<trkpt lat="46.36054" lon="25.5453"></trkpt>
<trkpt lat="46.36076" lon="25.54538"></trkpt>
<trkpt lat="46.36144" lon="25.54557"></trkpt>
<trkpt lat="46.36186" lon="25.54575"></trkpt>
<trkpt lat="46.36266" lon="25.54614"></trkpt>
<trkpt lat="46.36496" lon="25.5474"></trkpt>
<trkpt lat="46.36759" lon="25.54864"></trkpt>
<trkpt lat="46.36856" lon="25.54906"></trkpt>
<trkpt lat="46.3692" lon="25.54939"></trkpt>
<trkpt lat="46.36937" lon="25.54952"></trkpt>
<trkpt lat="46.36948" lon="25.54963"></trkpt>
<trkpt lat="46.36989" lon="25.55012"></trkpt>
<trkpt lat="46.37155" lon="25.55194"></trkpt>
<trkpt lat="46.37196" lon="25.55232"></trkpt>
<trkpt lat="46.3725" lon="25.55288"></trkpt>
<trkpt lat="46.37292" lon="25.5534"></trkpt>
<trkpt lat="46.3732" lon="25.55379"></trkpt>
<trkpt lat="46.37388" lon="25.55458"></trkpt>
<trkpt lat="46.37423" lon="25.5549"></trkpt>
<trkpt lat="46.37469" lon="25.55542"></trkpt>
<trkpt lat="46.37519" lon="25.5562"></trkpt>
<trkpt lat="46.37541" lon="25.55641"></trkpt>
<trkpt lat="46.37585" lon="25.5569"></trkpt>
<trkpt lat="46.37647" lon="25.55749"></trkpt>
<trkpt lat="46.37656" lon="25.55765"></trkpt>
<trkpt lat="46.3767" lon="25.55809"></trkpt>
<trkpt lat="46.37682" lon="25.55863"></trkpt>
<trkpt lat="46.3769" lon="25.55887"></trkpt>
<trkpt lat="46.37701" lon="25.5591"></trkpt>
<trkpt lat="46.37726" lon="25.55951"></trkpt>
<trkpt lat="46.37735" lon="25.55962"></trkpt>
<trkpt lat="46.3774" lon="25.55966"></trkpt>
<trkpt lat="46.37751" lon="25.55971"></trkpt>
<trkpt lat="46.37774" lon="25.55974"></trkpt>
<trkpt lat="46.37815" lon="25.55986"></trkpt>
<trkpt lat="46.37838" lon="25.55999"></trkpt>
<trkpt lat="46.37845" lon="25.56009"></trkpt>
<trkpt lat="46.3786" lon="25.56024"></trkpt>
<trkpt lat="46.37873" lon="25.56041"></trkpt>
<trkpt lat="46.37902" lon="25.56087"></trkpt>
<trkpt lat="46.37925" lon="25.56136"></trkpt>
<trkpt lat="46.37949" lon="25.56179"></trkpt>
<trkpt lat="46.37963" lon="25.56196"></trkpt>
<trkpt lat="46.37991" lon="25.56217"></trkpt>
<trkpt lat="46.38017" lon="25.56233"></trkpt>
<trkpt lat="46.38027" lon="25.56245"></trkpt>
<trkpt lat="46.38042" lon="25.56259"></trkpt>
<trkpt lat="46.38046" lon="25.56261"></trkpt>
<trkpt lat="46.3806" lon="25.56263"></trkpt>
<trkpt lat="46.38083" lon="25.56253"></trkpt>
<trkpt lat="46.38089" lon="25.56252"></trkpt>
<trkpt lat="46.38099" lon="25.56253"></trkpt>
<trkpt lat="46.38104" lon="25.56255"></trkpt>
<trkpt lat="46.38125" lon="25.56272"></trkpt>
<trkpt lat="46.38134" lon="25.56281"></trkpt>
<trkpt lat="46.38146" lon="25.5629"></trkpt>
<trkpt lat="46.38155" lon="25.56294"></trkpt>
<trkpt lat="46.38164" lon="25.56296"></trkpt>
<trkpt lat="46.38174" lon="25.56296"></trkpt>
<trkpt lat="46.38207" lon="25.56291"></trkpt>
<trkpt lat="46.38222" lon="25.56291"></trkpt>
<trkpt lat="46.38261" lon="25.56296"></trkpt>
<trkpt lat="46.38273" lon="25.56299"></trkpt>
<trkpt lat="46.38294" lon="25.56313"></trkpt>
<trkpt lat="46.38304" lon="25.56322"></trkpt>
<trkpt lat="46.38384" lon="25.5641"></trkpt>
<trkpt lat="46.38411" lon="25.56428"></trkpt>
<trkpt lat="46.3844" lon="25.56462"></trkpt>
<trkpt lat="46.38453" lon="25.56473"></trkpt>
<trkpt lat="46.38472" lon="25.56485"></trkpt>
<trkpt lat="46.38512" lon="25.56495"></trkpt>
<trkpt lat="46.38543" lon="25.565"></trkpt>
<trkpt lat="46.38604" lon="25.56517"></trkpt>
<trkpt lat="46.38678" lon="25.56551"></trkpt>
<trkpt lat="46.38693" lon="25.56563"></trkpt>
<trkpt lat="46.3872" lon="25.56592"></trkpt>
<trkpt lat="46.38844" lon="25.56695"></trkpt>
<trkpt lat="46.38861" lon="25.56713"></trkpt>
<trkpt lat="46.38868" lon="25.56718"></trkpt>
<trkpt lat="46.38886" lon="25.56725"></trkpt>
<trkpt lat="46.38931" lon="25.56734"></trkpt>
<trkpt lat="46.38934" lon="25.56734"></trkpt>
<trkpt lat="46.3895" lon="25.56738"></trkpt>
<trkpt lat="46.38958" lon="25.56745"></trkpt>
<trkpt lat="46.38971" lon="25.56763"></trkpt>
<trkpt lat="46.39013" lon="25.56805"></trkpt>
<trkpt lat="46.39043" lon="25.56823"></trkpt>
<trkpt lat="46.39062" lon="25.5683"></trkpt>
<trkpt lat="46.39093" lon="25.5683"></trkpt>
<trkpt lat="46.39149" lon="25.56841"></trkpt>
<trkpt lat="46.39169" lon="25.5685"></trkpt>
<trkpt lat="46.39194" lon="25.56866"></trkpt>
<trkpt lat="46.39212" lon="25.56874"></trkpt>
<trkpt lat="46.39234" lon="25.56877"></trkpt>
<trkpt lat="46.39296" lon="25.56879"></trkpt>
<trkpt lat="46.39361" lon="25.5689"></trkpt>
<trkpt lat="46.39392" lon="25.56892"></trkpt>
<trkpt lat="46.39393" lon="25.56893"></trkpt>
<trkpt lat="46.39409" lon="25.56897"></trkpt>
<trkpt lat="46.39435" lon="25.5691"></trkpt>
<trkpt lat="46.39462" lon="25.56921"></trkpt>
<trkpt lat="46.39522" lon="25.56934"></trkpt>
<trkpt lat="46.39556" lon="25.56957"></trkpt>
<trkpt lat="46.39573" lon="25.56971"></trkpt>
<trkpt lat="46.39597" lon="25.56987"></trkpt>
<trkpt lat="46.39613" lon="25.57"></trkpt>
<trkpt lat="46.39624" lon="25.57006"></trkpt>
<trkpt lat="46.39637" lon="25.57011"></trkpt>
<trkpt lat="46.39651" lon="25.57013"></trkpt>
<trkpt lat="46.39707" lon="25.57027"></trkpt>
<trkpt lat="46.39718" lon="25.57032"></trkpt>
<trkpt lat="46.39737" lon="25.57043"></trkpt>
<trkpt lat="46.39758" lon="25.57059"></trkpt>
<trkpt lat="46.39765" lon="25.57063"></trkpt>
<trkpt lat="46.39778" lon="25.57068"></trkpt>
<trkpt lat="46.39785" lon="25.57069"></trkpt>
<trkpt lat="46.3979" lon="25.57071"></trkpt>
<trkpt lat="46.39808" lon="25.57073"></trkpt>
<trkpt lat="46.39841" lon="25.57072"></trkpt>
<trkpt lat="46.39859" lon="25.57069"></trkpt>
<trkpt lat="46.39911" lon="25.57048"></trkpt>
<trkpt lat="46.39942" lon="25.57032"></trkpt>
<trkpt lat="46.3997" lon="25.5702"></trkpt>
<trkpt lat="46.40001" lon="25.57013"></trkpt>
<trkpt lat="46.40013" lon="25.57013"></trkpt>
<trkpt lat="46.4002" lon="25.57015"></trkpt>
<trkpt lat="46.40042" lon="25.57017"></trkpt>
<trkpt lat="46.40058" lon="25.57025"></trkpt>
<trkpt lat="46.40083" lon="25.57043"></trkpt>
<trkpt lat="46.40113" lon="25.57061"></trkpt>
<trkpt lat="46.40114" lon="25.57062"></trkpt>
<trkpt lat="46.40129" lon="25.57067"></trkpt>
<trkpt lat="46.40185" lon="25.57073"></trkpt>
<trkpt lat="46.40224" lon="25.57085"></trkpt>
<trkpt lat="46.40229" lon="25.57089"></trkpt>
<trkpt lat="46.40236" lon="25.57097"></trkpt>
<trkpt lat="46.40239" lon="25.57102"></trkpt>
<trkpt lat="46.40243" lon="25.57106"></trkpt>
<trkpt lat="46.40253" lon="25.57108"></trkpt>
<trkpt lat="46.40258" lon="25.57107"></trkpt>
<trkpt lat="46.4027" lon="25.57111"></trkpt>
<trkpt lat="46.40297" lon="25.57136"></trkpt>
<trkpt lat="46.40301" lon="25.57142"></trkpt>
<trkpt lat="46.40306" lon="25.57145"></trkpt>
<trkpt lat="46.40311" lon="25.57146"></trkpt>
<trkpt lat="46.40316" lon="25.57146"></trkpt>
<trkpt lat="46.4033" lon="25.57148"></trkpt>
<trkpt lat="46.40341" lon="25.57151"></trkpt>
<trkpt lat="46.40348" lon="25.57154"></trkpt>
<trkpt lat="46.40355" lon="25.57159"></trkpt>
<trkpt lat="46.40403" lon="25.57214"></trkpt>
<trkpt lat="46.40418" lon="25.57229"></trkpt>
<trkpt lat="46.40422" lon="25.57232"></trkpt>
<trkpt lat="46.40424" lon="25.57232"></trkpt>
<trkpt lat="46.40428" lon="25.57234"></trkpt>
<trkpt lat="46.40441" lon="25.57237"></trkpt>
<trkpt lat="46.40449" lon="25.57237"></trkpt>
<trkpt lat="46.40462" lon="25.57235"></trkpt>
<trkpt lat="46.40478" lon="25.5723"></trkpt>
<trkpt lat="46.40514" lon="25.57222"></trkpt>
<trkpt lat="46.40566" lon="25.57216"></trkpt>
<trkpt lat="46.40574" lon="25.57218"></trkpt>
<trkpt lat="46.40586" lon="25.57223"></trkpt>
<trkpt lat="46.406" lon="25.57232"></trkpt>
<trkpt lat="46.4061" lon="25.57243"></trkpt>
<trkpt lat="46.40647" lon="25.57277"></trkpt>
<trkpt lat="46.40663" lon="25.57281"></trkpt>
<trkpt lat="46.40703" lon="25.57284"></trkpt>
<trkpt lat="46.40729" lon="25.57289"></trkpt>
<trkpt lat="46.40743" lon="25.57293"></trkpt>
<trkpt lat="46.40757" lon="25.57301"></trkpt>
<trkpt lat="46.40769" lon="25.57305"></trkpt>
<trkpt lat="46.40817" lon="25.57328"></trkpt>
<trkpt lat="46.40829" lon="25.57335"></trkpt>
<trkpt lat="46.40879" lon="25.57377"></trkpt>
<trkpt lat="46.40892" lon="25.57385"></trkpt>
<trkpt lat="46.40935" lon="25.57399"></trkpt>
<trkpt lat="46.40956" lon="25.57404"></trkpt>
<trkpt lat="46.40979" lon="25.57407"></trkpt>
<trkpt lat="46.40993" lon="25.57407"></trkpt>
<trkpt lat="46.41036" lon="25.57414"></trkpt>
<trkpt lat="46.41048" lon="25.57414"></trkpt>
<trkpt lat="46.41053" lon="25.57413"></trkpt>
<trkpt lat="46.41061" lon="25.5741"></trkpt>
<trkpt lat="46.41088" lon="25.57396"></trkpt>
<trkpt lat="46.41104" lon="25.57391"></trkpt>
<trkpt lat="46.41143" lon="25.57385"></trkpt>
<trkpt lat="46.41156" lon="25.57387"></trkpt>
<trkpt lat="46.41165" lon="25.5739"></trkpt>
<trkpt lat="46.41188" lon="25.57402"></trkpt>
<trkpt lat="46.41224" lon="25.5743"></trkpt>
<trkpt lat="46.4123" lon="25.57432"></trkpt>
<trkpt lat="46.41236" lon="25.57433"></trkpt>
<trkpt lat="46.41242" lon="25.57432"></trkpt>
<trkpt lat="46.41248" lon="25.57428"></trkpt>
<trkpt lat="46.41251" lon="25.57425"></trkpt>
<trkpt lat="46.41258" lon="25.57415"></trkpt>
<trkpt lat="46.41267" lon="25.57387"></trkpt>
<trkpt lat="46.41273" lon="25.57373"></trkpt>
<trkpt lat="46.41276" lon="25.5737"></trkpt>
<trkpt lat="46.41281" lon="25.57367"></trkpt>
<trkpt lat="46.4129" lon="25.57365"></trkpt>
<trkpt lat="46.41308" lon="25.57364"></trkpt>
<trkpt lat="46.41316" lon="25.57365"></trkpt>
<trkpt lat="46.41318" lon="25.57366"></trkpt>
<trkpt lat="46.41341" lon="25.57367"></trkpt>
<trkpt lat="46.41395" lon="25.57378"></trkpt>
<trkpt lat="46.41418" lon="25.5738"></trkpt>
<trkpt lat="46.41431" lon="25.57384"></trkpt>
<trkpt lat="46.4144" lon="25.57392"></trkpt>
<trkpt lat="46.41447" lon="25.57406"></trkpt>
<trkpt lat="46.41453" lon="25.57426"></trkpt>
<trkpt lat="46.41456" lon="25.57432"></trkpt>
<trkpt lat="46.41464" lon="25.57443"></trkpt>
<trkpt lat="46.41484" lon="25.57462"></trkpt>
<trkpt lat="46.41502" lon="25.57484"></trkpt>
<trkpt lat="46.41518" lon="25.57496"></trkpt>
<trkpt lat="46.41534" lon="25.57503"></trkpt>
<trkpt lat="46.41546" lon="25.57503"></trkpt>
<trkpt lat="46.41551" lon="25.57501"></trkpt>
<trkpt lat="46.41552" lon="25.57501"></trkpt>
<trkpt lat="46.4157" lon="25.57492"></trkpt>
<trkpt lat="46.41594" lon="25.57474"></trkpt>
<trkpt lat="46.41615" lon="25.57462"></trkpt>
<trkpt lat="46.41639" lon="25.57452"></trkpt>
<trkpt lat="46.41666" lon="25.57449"></trkpt>
<trkpt lat="46.41676" lon="25.5745"></trkpt>
<trkpt lat="46.4169" lon="25.57453"></trkpt>
<trkpt lat="46.41737" lon="25.57471"></trkpt>
<trkpt lat="46.41741" lon="25.57471"></trkpt>
<trkpt lat="46.41745" lon="25.57472"></trkpt>
<trkpt lat="46.41769" lon="25.57473"></trkpt>
<trkpt lat="46.4179" lon="25.57476"></trkpt>
<trkpt lat="46.4182" lon="25.57488"></trkpt>
<trkpt lat="46.41849" lon="25.5749"></trkpt>
<trkpt lat="46.41882" lon="25.57484"></trkpt>
<trkpt lat="46.41902" lon="25.57483"></trkpt>
<trkpt lat="46.4191" lon="25.57484"></trkpt>
<trkpt lat="46.41913" lon="25.57483"></trkpt>
<trkpt lat="46.41917" lon="25.57484"></trkpt>
<trkpt lat="46.41919" lon="25.57486"></trkpt>
<trkpt lat="46.4192" lon="25.57486"></trkpt>
<trkpt lat="46.41928" lon="25.57495"></trkpt>
<trkpt lat="46.41932" lon="25.57503"></trkpt>
<trkpt lat="46.41932" lon="25.57504"></trkpt>
<trkpt lat="46.41936" lon="25.57516"></trkpt>
<trkpt lat="46.41949" lon="25.5758"></trkpt>
<trkpt lat="46.41952" lon="25.57586"></trkpt>
<trkpt lat="46.41965" lon="25.57602"></trkpt>
<trkpt lat="46.41976" lon="25.57611"></trkpt>
<trkpt lat="46.41985" lon="25.57616"></trkpt>
<trkpt lat="46.42003" lon="25.57618"></trkpt>
<trkpt lat="46.42029" lon="25.57618"></trkpt>
<trkpt lat="46.42038" lon="25.57619"></trkpt>
<trkpt lat="46.42056" lon="25.57623"></trkpt>
<trkpt lat="46.42097" lon="25.57652"></trkpt>
<trkpt lat="46.42117" lon="25.57675"></trkpt>
<trkpt lat="46.42126" lon="25.57692"></trkpt>
<trkpt lat="46.42139" lon="25.57742"></trkpt>
<trkpt lat="46.42144" lon="25.57756"></trkpt>
<trkpt lat="46.42155" lon="25.57779"></trkpt>
<trkpt lat="46.42165" lon="25.57789"></trkpt>
<trkpt lat="46.42181" lon="25.578"></trkpt>
<trkpt lat="46.42194" lon="25.57806"></trkpt>
<trkpt lat="46.42203" lon="25.57813"></trkpt>
<trkpt lat="46.42213" lon="25.5783"></trkpt>
<trkpt lat="46.42222" lon="25.57857"></trkpt>
<trkpt lat="46.42243" lon="25.57935"></trkpt>
<trkpt lat="46.42255" lon="25.5796"></trkpt>
<trkpt lat="46.42264" lon="25.57975"></trkpt>
<trkpt lat="46.42275" lon="25.5799"></trkpt>
<trkpt lat="46.42301" lon="25.5802"></trkpt>
<trkpt lat="46.42333" lon="25.58066"></trkpt>
<trkpt lat="46.42342" lon="25.58076"></trkpt>
<trkpt lat="46.42368" lon="25.58094"></trkpt>
<trkpt lat="46.42408" lon="25.58133"></trkpt>
<trkpt lat="46.42421" lon="25.58151"></trkpt>
<trkpt lat="46.42435" lon="25.58184"></trkpt>
<trkpt lat="46.42444" lon="25.58217"></trkpt>
<trkpt lat="46.42445" lon="25.58231"></trkpt>
<trkpt lat="46.42448" lon="25.58247"></trkpt>
<trkpt lat="46.42455" lon="25.58343"></trkpt>
<trkpt lat="46.42452" lon="25.58369"></trkpt>
<trkpt lat="46.42441" lon="25.58407"></trkpt>
<trkpt lat="46.42434" lon="25.58421"></trkpt>
<trkpt lat="46.42429" lon="25.58439"></trkpt>
<trkpt lat="46.42426" lon="25.58465"></trkpt>
<trkpt lat="46.42428" lon="25.58495"></trkpt>
<trkpt lat="46.42434" lon="25.58513"></trkpt>
<trkpt lat="46.42436" lon="25.58517"></trkpt>
<trkpt lat="46.42441" lon="25.58524"></trkpt>
<trkpt lat="46.42451" lon="25.58533"></trkpt>
<trkpt lat="46.42489" lon="25.58562"></trkpt>
<trkpt lat="46.42496" lon="25.5857"></trkpt>
<trkpt lat="46.42503" lon="25.58585"></trkpt>
<trkpt lat="46.42508" lon="25.58609"></trkpt>
<trkpt lat="46.42519" lon="25.58702"></trkpt>
<trkpt lat="46.42528" lon="25.58744"></trkpt>
<trkpt lat="46.42564" lon="25.58835"></trkpt>
<trkpt lat="46.42594" lon="25.58892"></trkpt>
<trkpt lat="46.42607" lon="25.58935"></trkpt>
<trkpt lat="46.42611" lon="25.58944"></trkpt>
<trkpt lat="46.42611" lon="25.58945"></trkpt>
<trkpt lat="46.42614" lon="25.58954"></trkpt>
<trkpt lat="46.42626" lon="25.58976"></trkpt>
<trkpt lat="46.42677" lon="25.59046"></trkpt>
<trkpt lat="46.4268" lon="25.59052"></trkpt>
<trkpt lat="46.42683" lon="25.59056"></trkpt>
<trkpt lat="46.42689" lon="25.59069"></trkpt>
<trkpt lat="46.42696" lon="25.59091"></trkpt>
<trkpt lat="46.42713" lon="25.59127"></trkpt>
<trkpt lat="46.42717" lon="25.59132"></trkpt>
<trkpt lat="46.42718" lon="25.59134"></trkpt>
<trkpt lat="46.4272" lon="25.59134"></trkpt>
<trkpt lat="46.42722" lon="25.59135"></trkpt>
<trkpt lat="46.42723" lon="25.59134"></trkpt>
<trkpt lat="46.42725" lon="25.59134"></trkpt>
<trkpt lat="46.42728" lon="25.59131"></trkpt>
<trkpt lat="46.42729" lon="25.59127"></trkpt>
<trkpt lat="46.4273" lon="25.59125"></trkpt>
<trkpt lat="46.4273" lon="25.59123"></trkpt>
<trkpt lat="46.42724" lon="25.59103"></trkpt>
<trkpt lat="46.42718" lon="25.5909"></trkpt>
<trkpt lat="46.42691" lon="25.58997"></trkpt>
<trkpt lat="46.42686" lon="25.58965"></trkpt>
<trkpt lat="46.42681" lon="25.58813"></trkpt>
<trkpt lat="46.42675" lon="25.58759"></trkpt>
<trkpt lat="46.42683" lon="25.58632"></trkpt>
<trkpt lat="46.42701" lon="25.58507"></trkpt>
<trkpt lat="46.42702" lon="25.58486"></trkpt>
<trkpt lat="46.42701" lon="25.58465"></trkpt>
<trkpt lat="46.42686" lon="25.58376"></trkpt>
<trkpt lat="46.42686" lon="25.58358"></trkpt>
<trkpt lat="46.4269" lon="25.58344"></trkpt>
<trkpt lat="46.42697" lon="25.58333"></trkpt>
<trkpt lat="46.42709" lon="25.58325"></trkpt>
<trkpt lat="46.42761" lon="25.58308"></trkpt>
<trkpt lat="46.42793" lon="25.58288"></trkpt>
<trkpt lat="46.4282" lon="25.58268"></trkpt>
<trkpt lat="46.42821" lon="25.58268"></trkpt>
<trkpt lat="46.42839" lon="25.58254"></trkpt>
<trkpt lat="46.42855" lon="25.58237"></trkpt>
<trkpt lat="46.42862" lon="25.58233"></trkpt>
<trkpt lat="46.42874" lon="25.58224"></trkpt>
<trkpt lat="46.42878" lon="25.58223"></trkpt>
<trkpt lat="46.42882" lon="25.5822"></trkpt>
<trkpt lat="46.42895" lon="25.58215"></trkpt>
<trkpt lat="46.42899" lon="25.58215"></trkpt>
<trkpt lat="46.4292" lon="25.58211"></trkpt>
<trkpt lat="46.42941" lon="25.5821"></trkpt>
<trkpt lat="46.42947" lon="25.58212"></trkpt>
<trkpt lat="46.42951" lon="25.58212"></trkpt>
<trkpt lat="46.42955" lon="25.58214"></trkpt>
<trkpt lat="46.42957" lon="25.58214"></trkpt>
<trkpt lat="46.42971" lon="25.58221"></trkpt>
<trkpt lat="46.42974" lon="25.58224"></trkpt>
<trkpt lat="46.42974" lon="25.58225"></trkpt>
<trkpt lat="46.42975" lon="25.58226"></trkpt>
<trkpt lat="46.42975" lon="25.58234"></trkpt>
<trkpt lat="46.42972" lon="25.58241"></trkpt>
<trkpt lat="46.42966" lon="25.58249"></trkpt>
<trkpt lat="46.42955" lon="25.58258"></trkpt>
<trkpt lat="46.42954" lon="25.58258"></trkpt>
<trkpt lat="46.42941" lon="25.58263"></trkpt>
<trkpt lat="46.4294" lon="25.58263"></trkpt>
<trkpt lat="46.42937" lon="25.58265"></trkpt>
<trkpt lat="46.42905" lon="25.58275"></trkpt>
<trkpt lat="46.42893" lon="25.58282"></trkpt>
<trkpt lat="46.42891" lon="25.58284"></trkpt>
<trkpt lat="46.42888" lon="25.58285"></trkpt>
<trkpt lat="46.42883" lon="25.58293"></trkpt>
<trkpt lat="46.4288" lon="25.58296"></trkpt>
<trkpt lat="46.42857" lon="25.58342"></trkpt>
<trkpt lat="46.42854" lon="25.5835"></trkpt>
<trkpt lat="46.4285" lon="25.58357"></trkpt>
<trkpt lat="46.42845" lon="25.58373"></trkpt>
<trkpt lat="46.42844" lon="25.58374"></trkpt>
<trkpt lat="46.42842" lon="25.5838"></trkpt>
<trkpt lat="46.42842" lon="25.58382"></trkpt>
<trkpt lat="46.42838" lon="25.58395"></trkpt>
<trkpt lat="46.42838" lon="25.58398"></trkpt>
<trkpt lat="46.42836" lon="25.58404"></trkpt>
<trkpt lat="46.42836" lon="25.58412"></trkpt>
<trkpt lat="46.42835" lon="25.58415"></trkpt>
<trkpt lat="46.42835" lon="25.5842"></trkpt>
<trkpt lat="46.42836" lon="25.58423"></trkpt>
<trkpt lat="46.42836" lon="25.58424"></trkpt>
<trkpt lat="46.42837" lon="25.58426"></trkpt>
<trkpt lat="46.4284" lon="25.58429"></trkpt>
<trkpt lat="46.42842" lon="25.58429"></trkpt>
<trkpt lat="46.42843" lon="25.5843"></trkpt>
<trkpt lat="46.42844" lon="25.58429"></trkpt>
<trkpt lat="46.42846" lon="25.58429"></trkpt>
<trkpt lat="46.42848" lon="25.58428"></trkpt>
<trkpt lat="46.42851" lon="25.58425"></trkpt>
<trkpt lat="46.42858" lon="25.5842"></trkpt>
<trkpt lat="46.42866" lon="25.5841"></trkpt>
<trkpt lat="46.42867" lon="25.58408"></trkpt>
<trkpt lat="46.42868" lon="25.58407"></trkpt>
<trkpt lat="46.42871" lon="25.58402"></trkpt>
<trkpt lat="46.4289" lon="25.58379"></trkpt>
<trkpt lat="46.42897" lon="25.58375"></trkpt>
<trkpt lat="46.429" lon="25.58372"></trkpt>
<trkpt lat="46.42904" lon="25.5837"></trkpt>
<trkpt lat="46.42906" lon="25.58368"></trkpt>
<trkpt lat="46.42911" lon="25.58368"></trkpt>
<trkpt lat="46.42915" lon="25.58367"></trkpt>
<trkpt lat="46.42928" lon="25.58367"></trkpt>
<trkpt lat="46.4294" lon="25.58364"></trkpt>
<trkpt lat="46.42959" lon="25.58364"></trkpt>
<trkpt lat="46.42972" lon="25.58367"></trkpt>
<trkpt lat="46.42975" lon="25.58367"></trkpt>
<trkpt lat="46.42978" lon="25.58369"></trkpt>
<trkpt lat="46.42981" lon="25.5837"></trkpt>
<trkpt lat="46.42982" lon="25.5837"></trkpt>
<trkpt lat="46.43005" lon="25.58384"></trkpt>
<trkpt lat="46.43021" lon="25.58397"></trkpt>
<trkpt lat="46.43028" lon="25.58401"></trkpt>
<trkpt lat="46.43033" lon="25.58405"></trkpt>
<trkpt lat="46.43035" lon="25.58408"></trkpt>
<trkpt lat="46.43038" lon="25.5841"></trkpt>
<trkpt lat="46.43044" lon="25.58416"></trkpt>
<trkpt lat="46.43046" lon="25.58419"></trkpt>
<trkpt lat="46.43051" lon="25.58424"></trkpt>
<trkpt lat="46.43054" lon="25.58425"></trkpt>
<trkpt lat="46.43061" lon="25.58429"></trkpt>
<trkpt lat="46.43065" lon="25.58428"></trkpt>
<trkpt lat="46.43072" lon="25.58428"></trkpt>
<trkpt lat="46.43077" lon="25.58426"></trkpt>
<trkpt lat="46.43085" lon="25.58421"></trkpt>
<trkpt lat="46.43095" lon="25.58413"></trkpt>
<trkpt lat="46.43122" lon="25.58387"></trkpt>
<trkpt lat="46.43131" lon="25.58381"></trkpt>
<trkpt lat="46.43153" lon="25.58374"></trkpt>
<trkpt lat="46.43156" lon="25.58374"></trkpt>
<trkpt lat="46.4316" lon="25.58373"></trkpt>
<trkpt lat="46.43167" lon="25.58369"></trkpt>
<trkpt lat="46.4317" lon="25.58368"></trkpt>
<trkpt lat="46.43174" lon="25.58365"></trkpt>
<trkpt lat="46.43188" lon="25.58351"></trkpt>
<trkpt lat="46.43195" lon="25.58342"></trkpt>
<trkpt lat="46.43201" lon="25.58336"></trkpt>
<trkpt lat="46.43208" lon="25.58327"></trkpt>
<trkpt lat="46.43232" lon="25.58306"></trkpt>
<trkpt lat="46.43241" lon="25.58296"></trkpt>
<trkpt lat="46.43249" lon="25.58285"></trkpt>
<trkpt lat="46.4326" lon="25.58263"></trkpt>
<trkpt lat="46.43263" lon="25.58255"></trkpt>
<trkpt lat="46.43265" lon="25.58252"></trkpt>
<trkpt lat="46.43274" lon="25.58227"></trkpt>
<trkpt lat="46.4328" lon="25.58201"></trkpt>
<trkpt lat="46.43285" lon="25.58146"></trkpt>
<trkpt lat="46.43284" lon="25.58111"></trkpt>
<trkpt lat="46.43282" lon="25.581"></trkpt>
<trkpt lat="46.43282" lon="25.58097"></trkpt>
<trkpt lat="46.43279" lon="25.58089"></trkpt>
<trkpt lat="46.43279" lon="25.58085"></trkpt>
<trkpt lat="46.43277" lon="25.58082"></trkpt>
<trkpt lat="46.43277" lon="25.58079"></trkpt>
<trkpt lat="46.43275" lon="25.58076"></trkpt>
<trkpt lat="46.43273" lon="25.5807"></trkpt>
<trkpt lat="46.43269" lon="25.58063"></trkpt>
<trkpt lat="46.43267" lon="25.58061"></trkpt>
<trkpt lat="46.43264" lon="25.58056"></trkpt>
<trkpt lat="46.4326" lon="25.58052"></trkpt>
<trkpt lat="46.43259" lon="25.5805"></trkpt>
<trkpt lat="46.43257" lon="25.58048"></trkpt>
<trkpt lat="46.43249" lon="25.58034"></trkpt>
<trkpt lat="46.43232" lon="25.57976"></trkpt>
<trkpt lat="46.43232" lon="25.5797"></trkpt>
<trkpt lat="46.43231" lon="25.57965"></trkpt>
<trkpt lat="46.43231" lon="25.57952"></trkpt>
<trkpt lat="46.4323" lon="25.57951"></trkpt>
<trkpt lat="46.4323" lon="25.57944"></trkpt>
<trkpt lat="46.43229" lon="25.57941"></trkpt>
<trkpt lat="46.43229" lon="25.57938"></trkpt>
<trkpt lat="46.43228" lon="25.57935"></trkpt>
<trkpt lat="46.43228" lon="25.57932"></trkpt>
<trkpt lat="46.43227" lon="25.57929"></trkpt>
<trkpt lat="46.43224" lon="25.57924"></trkpt>
<trkpt lat="46.4322" lon="25.5792"></trkpt>
<trkpt lat="46.43214" lon="25.57917"></trkpt>
<trkpt lat="46.43195" lon="25.57913"></trkpt>
<trkpt lat="46.43125" lon="25.57908"></trkpt>
<trkpt lat="46.43103" lon="25.57904"></trkpt>
<trkpt lat="46.43095" lon="25.57899"></trkpt>
<trkpt lat="46.43088" lon="25.57892"></trkpt>
<trkpt lat="46.43085" lon="25.57881"></trkpt>
<trkpt lat="46.43085" lon="25.57864"></trkpt>
<trkpt lat="46.43089" lon="25.57834"></trkpt>
<trkpt lat="46.43089" lon="25.57817"></trkpt>
<trkpt lat="46.43087" lon="25.57804"></trkpt>
<trkpt lat="46.43087" lon="25.578"></trkpt>
<trkpt lat="46.43086" lon="25.57796"></trkpt>
<trkpt lat="46.43083" lon="25.57791"></trkpt>
<trkpt lat="46.4308" lon="25.57783"></trkpt>
<trkpt lat="46.43077" lon="25.57781"></trkpt>
<trkpt lat="46.43072" lon="25.57776"></trkpt>
<trkpt lat="46.43035" lon="25.57758"></trkpt>
<trkpt lat="46.43033" lon="25.57758"></trkpt>
<trkpt lat="46.43016" lon="25.5775"></trkpt>
<trkpt lat="46.4301" lon="25.57746"></trkpt>
<trkpt lat="46.43007" lon="25.57745"></trkpt>
<trkpt lat="46.43004" lon="25.57743"></trkpt>
<trkpt lat="46.43001" lon="25.57742"></trkpt>
<trkpt lat="46.42989" lon="25.57742"></trkpt>
<trkpt lat="46.42985" lon="25.57744"></trkpt>
<trkpt lat="46.42975" lon="25.57747"></trkpt>
<trkpt lat="46.42971" lon="25.57747"></trkpt>
<trkpt lat="46.42968" lon="25.57748"></trkpt>
<trkpt lat="46.42965" lon="25.57748"></trkpt>
<trkpt lat="46.42961" lon="25.57749"></trkpt>
<trkpt lat="46.42958" lon="25.57749"></trkpt>
<trkpt lat="46.42948" lon="25.57746"></trkpt>
<trkpt lat="46.42942" lon="25.57742"></trkpt>
<trkpt lat="46.42932" lon="25.57738"></trkpt>
<trkpt lat="46.42929" lon="25.57736"></trkpt>
<trkpt lat="46.42926" lon="25.57735"></trkpt>
<trkpt lat="46.42905" lon="25.57723"></trkpt>
<trkpt lat="46.42902" lon="25.57722"></trkpt>
<trkpt lat="46.42895" lon="25.57718"></trkpt>
<trkpt lat="46.4289" lon="25.57714"></trkpt>
<trkpt lat="46.42885" lon="25.57703"></trkpt>
<trkpt lat="46.42885" lon="25.577"></trkpt>
<trkpt lat="46.42884" lon="25.57698"></trkpt>
<trkpt lat="46.42884" lon="25.57694"></trkpt>
<trkpt lat="46.42885" lon="25.57692"></trkpt>
<trkpt lat="46.42885" lon="25.5769"></trkpt>
<trkpt lat="46.42887" lon="25.57687"></trkpt>
<trkpt lat="46.42898" lon="25.57677"></trkpt>
<trkpt lat="46.42903" lon="25.57676"></trkpt>
<trkpt lat="46.42914" lon="25.57676"></trkpt>
<trkpt lat="46.42922" lon="25.57675"></trkpt>
<trkpt lat="46.42954" lon="25.57677"></trkpt>
<trkpt lat="46.42996" lon="25.57676"></trkpt>
<trkpt lat="46.4303" lon="25.57679"></trkpt>
<trkpt lat="46.4308" lon="25.57692"></trkpt>
<trkpt lat="46.43099" lon="25.577"></trkpt>
<trkpt lat="46.43103" lon="25.57701"></trkpt>
<trkpt lat="46.43108" lon="25.57704"></trkpt>
<trkpt lat="46.43116" lon="25.57704"></trkpt>
<trkpt lat="46.43119" lon="25.57703"></trkpt>
<trkpt lat="46.4312" lon="25.57703"></trkpt>
<trkpt lat="46.43122" lon="25.57701"></trkpt>
<trkpt lat="46.43122" lon="25.577"></trkpt>
<trkpt lat="46.43124" lon="25.57696"></trkpt>
<trkpt lat="46.43125" lon="25.57692"></trkpt>
<trkpt lat="46.43125" lon="25.57649"></trkpt>
<trkpt lat="46.43126" lon="25.57644"></trkpt>
<trkpt lat="46.43127" lon="25.57642"></trkpt>
<trkpt lat="46.43128" lon="25.57638"></trkpt>
<trkpt lat="46.43129" lon="25.57636"></trkpt>
<trkpt lat="46.43131" lon="25.57634"></trkpt>
<trkpt lat="46.43133" lon="25.57633"></trkpt>
<trkpt lat="46.43135" lon="25.57631"></trkpt>
<trkpt lat="46.43138" lon="25.5763"></trkpt>
<trkpt lat="46.4314" lon="25.57631"></trkpt>
<trkpt lat="46.43142" lon="25.57631"></trkpt>
<trkpt lat="46.43158" lon="25.57641"></trkpt>
<trkpt lat="46.4316" lon="25.57644"></trkpt>
<trkpt lat="46.43171" lon="25.57653"></trkpt>
<trkpt lat="46.43176" lon="25.57656"></trkpt>
<trkpt lat="46.4318" lon="25.5766"></trkpt>
<trkpt lat="46.43208" lon="25.57676"></trkpt>
<trkpt lat="46.43234" lon="25.57684"></trkpt>
<trkpt lat="46.43244" lon="25.57684"></trkpt>
<trkpt lat="46.4325" lon="25.57683"></trkpt>
<trkpt lat="46.43268" lon="25.57677"></trkpt>
<trkpt lat="46.43279" lon="25.57671"></trkpt>
<trkpt lat="46.43294" lon="25.57667"></trkpt>
<trkpt lat="46.4331" lon="25.57667"></trkpt>
<trkpt lat="46.43326" lon="25.57669"></trkpt>
<trkpt lat="46.43348" lon="25.57674"></trkpt>
<trkpt lat="46.43357" lon="25.57679"></trkpt>
<trkpt lat="46.43361" lon="25.57683"></trkpt>
<trkpt lat="46.43362" lon="25.57683"></trkpt>
<trkpt lat="46.43364" lon="25.57686"></trkpt>
<trkpt lat="46.43366" lon="25.57687"></trkpt>
<trkpt lat="46.43368" lon="25.5769"></trkpt>
<trkpt lat="46.43376" lon="25.57698"></trkpt>
<trkpt lat="46.43381" lon="25.57707"></trkpt>
<trkpt lat="46.43387" lon="25.57714"></trkpt>
<trkpt lat="46.43399" lon="25.57733"></trkpt>
<trkpt lat="46.4342" lon="25.57776"></trkpt>
<trkpt lat="46.43435" lon="25.57802"></trkpt>
<trkpt lat="46.43465" lon="25.57844"></trkpt>
<trkpt lat="46.43471" lon="25.57849"></trkpt>
<trkpt lat="46.43472" lon="25.57851"></trkpt>
<trkpt lat="46.4348" lon="25.57858"></trkpt>
<trkpt lat="46.43484" lon="25.5786"></trkpt>
<trkpt lat="46.43487" lon="25.57863"></trkpt>
<trkpt lat="46.43488" lon="25.57863"></trkpt>
<trkpt lat="46.43503" lon="25.57872"></trkpt>
<trkpt lat="46.43509" lon="25.57874"></trkpt>
<trkpt lat="46.43512" lon="25.57876"></trkpt>
<trkpt lat="46.43523" lon="25.5788"></trkpt>
<trkpt lat="46.43526" lon="25.57882"></trkpt>
<trkpt lat="46.43535" lon="25.57885"></trkpt>
<trkpt lat="46.43548" lon="25.57887"></trkpt>
<trkpt lat="46.43562" lon="25.57891"></trkpt>
<trkpt lat="46.43567" lon="25.57894"></trkpt>
<trkpt lat="46.43569" lon="25.57896"></trkpt>
<trkpt lat="46.43584" lon="25.57906"></trkpt>
<trkpt lat="46.43606" lon="25.57913"></trkpt>
<trkpt lat="46.43624" lon="25.57916"></trkpt>
<trkpt lat="46.43626" lon="25.57917"></trkpt>
<trkpt lat="46.4363" lon="25.57918"></trkpt>
<trkpt lat="46.43642" lon="25.57923"></trkpt>
<trkpt lat="46.43647" lon="25.57926"></trkpt>
<trkpt lat="46.43652" lon="25.57931"></trkpt>
<trkpt lat="46.43655" lon="25.57933"></trkpt>
<trkpt lat="46.43664" lon="25.57943"></trkpt>
<trkpt lat="46.43666" lon="25.57947"></trkpt>
<trkpt lat="46.43677" lon="25.57959"></trkpt>
<trkpt lat="46.43712" lon="25.5799"></trkpt>
<trkpt lat="46.43718" lon="25.57997"></trkpt>
<trkpt lat="46.43721" lon="25.57999"></trkpt>
<trkpt lat="46.43727" lon="25.58005"></trkpt>
<trkpt lat="46.43735" lon="25.58018"></trkpt>
<trkpt lat="46.43737" lon="25.58024"></trkpt>
<trkpt lat="46.43739" lon="25.58027"></trkpt>
<trkpt lat="46.43748" lon="25.58052"></trkpt>
<trkpt lat="46.43764" lon="25.58084"></trkpt>
<trkpt lat="46.43767" lon="25.58088"></trkpt>
<trkpt lat="46.43775" lon="25.58096"></trkpt>
<trkpt lat="46.43792" lon="25.58106"></trkpt>
<trkpt lat="46.43803" lon="25.5811"></trkpt>
<trkpt lat="46.43805" lon="25.5811"></trkpt>
<trkpt lat="46.43808" lon="25.58111"></trkpt>
<trkpt lat="46.43815" lon="25.58111"></trkpt>
<trkpt lat="46.43821" lon="25.58112"></trkpt>
<trkpt lat="46.43827" lon="25.58111"></trkpt>
<trkpt lat="46.43837" lon="25.58111"></trkpt>
<trkpt lat="46.43841" lon="25.5811"></trkpt>
<trkpt lat="46.43847" lon="25.5811"></trkpt>
<trkpt lat="46.43852" lon="25.58108"></trkpt>
<trkpt lat="46.43916" lon="25.58096"></trkpt>
<trkpt lat="46.43928" lon="25.58096"></trkpt>
<trkpt lat="46.43938" lon="25.58094"></trkpt>
<trkpt lat="46.43954" lon="25.58093"></trkpt>
<trkpt lat="46.43966" lon="25.58091"></trkpt>
<trkpt lat="46.43984" lon="25.58086"></trkpt>
<trkpt lat="46.44016" lon="25.58071"></trkpt>
<trkpt lat="46.44033" lon="25.5806"></trkpt>
<trkpt lat="46.4405" lon="25.58052"></trkpt>
<trkpt lat="46.44059" lon="25.5805"></trkpt>
<trkpt lat="46.44063" lon="25.5805"></trkpt>
<trkpt lat="46.44068" lon="25.58049"></trkpt>
<trkpt lat="46.44069" lon="25.58048"></trkpt>
<trkpt lat="46.44072" lon="25.58048"></trkpt>
<trkpt lat="46.44081" lon="25.58045"></trkpt>
<trkpt lat="46.44084" lon="25.58042"></trkpt>
<trkpt lat="46.44092" lon="25.5803"></trkpt>
<trkpt lat="46.44095" lon="25.58024"></trkpt>
<trkpt lat="46.44098" lon="25.5802"></trkpt>
<trkpt lat="46.44105" lon="25.58015"></trkpt>
<trkpt lat="46.44109" lon="25.58011"></trkpt>
<trkpt lat="46.44114" lon="25.5801"></trkpt>
<trkpt lat="46.44124" lon="25.58006"></trkpt>
</trkseg>
</trk>
</gpx>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

View File

@@ -173,6 +173,11 @@
<i class="fas fa-chart-bar"></i> Analytics
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'admin.mail_settings' }}" href="{{ url_for('admin.mail_settings') }}">
<i class="fas fa-envelope"></i> Mail Server Settings
</a>
</li>
</ul>
<div class="sidebar-heading">
@@ -224,10 +229,18 @@
document.getElementById('sidebar').classList.toggle('show');
});
// Auto-refresh stats every 30 seconds
setTimeout(function() {
location.reload();
}, 30000);
// Conditional auto-refresh logic
{% if request.endpoint == 'admin.mail_settings' %}
{% if settings and settings.enabled %}
setTimeout(function() {
location.reload();
}, 30000);
{% endif %}
{% else %}
setTimeout(function() {
location.reload();
}, 30000);
{% endif %}
</script>
</body>
</html>

View File

@@ -121,10 +121,88 @@
</div>
</div>
<!-- Password Reset Management -->
<div class="row mb-4">
<div class="col-xl-6 col-md-6 mb-4">
<div class="card border-left-danger h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs fw-bold text-danger text-uppercase mb-1">Password Reset Requests</div>
<div class="h5 mb-0 fw-bold text-gray-800">
<a href="{{ url_for('admin.password_reset_requests') }}" class="text-decoration-none text-dark">
{{ pending_password_requests or 0 }}
</a>
</div>
<div class="small text-muted">Pending requests need attention</div>
</div>
<div class="col-auto">
<i class="fas fa-key fa-2x text-gray-300"></i>
</div>
</div>
<div class="mt-3">
<a href="{{ url_for('admin.password_reset_requests') }}" class="btn btn-danger btn-sm">
<i class="fas fa-cogs me-1"></i>Manage Requests
</a>
</div>
</div>
</div>
</div>
<div class="col-xl-6 col-md-6 mb-4">
<div class="card border-left-secondary h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs fw-bold text-secondary text-uppercase mb-1">Active Reset Tokens</div>
<div class="h5 mb-0 fw-bold text-gray-800">{{ active_reset_tokens or 0 }}</div>
<div class="small text-muted">Unused tokens (24h expiry)</div>
</div>
<div class="col-auto">
<i class="fas fa-link fa-2x text-gray-300"></i>
</div>
</div>
<div class="mt-3">
<a href="{{ url_for('admin.password_reset_tokens') }}" class="btn btn-secondary btn-sm">
<i class="fas fa-list me-1"></i>View Tokens
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Content Overview -->
<div class="row">
<!-- Chat Management -->
<div class="col-lg-4 mb-4">
<div class="card border-left-info h-100">
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
<h6 class="m-0 fw-bold text-info">
<i class="fas fa-comments me-2"></i>Chat Management
</h6>
</div>
<div class="card-body">
<div class="text-center mb-3">
<div class="h4 mb-0 text-gray-800">{{ total_chat_rooms or 0 }}</div>
<small class="text-muted">Total Chat Rooms</small>
</div>
<div class="mb-3">
<div class="small text-muted mb-1">Active Rooms: {{ active_chat_rooms or 0 }}</div>
<div class="small text-muted mb-1">Linked to Posts: {{ linked_chat_rooms or 0 }}</div>
<div class="small text-muted">Recent Messages: {{ recent_chat_messages or 0 }}</div>
</div>
<div class="text-center">
<a href="{{ url_for('admin.manage_chats') }}" class="btn btn-info btn-block">
<i class="fas fa-cogs me-1"></i>Manage Chats
</a>
</div>
</div>
</div>
</div>
<!-- Recent Posts -->
<div class="col-lg-6 mb-4">
<div class="col-lg-4 mb-4">
<div class="card">
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
<h6 class="m-0 fw-bold text-primary">Recent Posts</h6>
@@ -161,7 +239,7 @@
</div>
<!-- Most Viewed Posts -->
<div class="col-lg-6 mb-4">
<div class="col-lg-4 mb-4">
<div class="card">
<div class="card-header py-3">
<h6 class="m-0 fw-bold text-primary">Most Viewed Posts</h6>

View File

@@ -0,0 +1,78 @@
{% extends 'admin/base.html' %}
{% block admin_content %}
<h2>Mail Server Settings</h2>
<div class="alert alert-info" style="max-width:600px;">
<strong>Recommended Gmail SMTP Settings:</strong><br>
<ul style="margin-bottom:0;">
<li><b>Server:</b> smtp.gmail.com</li>
<li><b>Port:</b> 587</li>
<li><b>Use TLS:</b> True</li>
<li><b>Username:</b> your Gmail address (e.g. yourname@gmail.com)</li>
<li><b>Password:</b> your Gmail <b>App Password</b> (not your regular password)</li>
<li><b>Default sender:</b> your Gmail address (e.g. yourname@gmail.com)</li>
</ul>
<small>To use Gmail SMTP, you must create an <a href="https://myaccount.google.com/apppasswords" target="_blank">App Password</a> in your Google Account security settings.</small>
</div>
<form method="post">
<div class="form-group">
<label for="enabled">Enable Email Sending</label>
<input type="checkbox" id="enabled" name="enabled" value="1" {% if settings and settings.enabled %}checked{% endif %}>
</div>
<div class="form-group">
<label for="provider">Provider</label>
<select id="provider" name="provider" class="form-control">
<option value="smtp" {% if settings and settings.provider == 'smtp' %}selected{% endif %}>SMTP</option>
<option value="mailrise" {% if settings and settings.provider == 'mailrise' %}selected{% endif %}>Mailrise</option>
</select>
</div>
<div class="form-group">
<label for="server">Server</label>
<input type="text" id="server" name="server" class="form-control" value="{{ settings.server if settings else '' }}">
</div>
<div class="form-group">
<label for="port">Port</label>
<input type="number" id="port" name="port" class="form-control" value="{{ settings.port if settings else '' }}">
</div>
<div class="form-group">
<label for="use_tls">Use TLS</label>
<input type="checkbox" id="use_tls" name="use_tls" value="1" {% if settings and settings.use_tls %}checked{% endif %}>
</div>
<div class="form-group">
<label for="username">Username</label>
<input type="text" id="username" name="username" class="form-control" value="{{ settings.username if settings else '' }}">
</div>
<div class="form-group">
<label for="password">Password</label>
<input type="password" id="password" name="password" class="form-control" value="{{ settings.password if settings else '' }}">
</div>
<div class="form-group">
<label for="default_sender">Default Sender</label>
<input type="text" id="default_sender" name="default_sender" class="form-control" value="{{ settings.default_sender if settings else '' }}">
</div>
<button type="submit" class="btn btn-primary">Save Settings</button>
</form>
<hr>
<h3>Sent Emails Log</h3>
<table class="table table-striped">
<thead>
<tr>
<th>Date</th>
<th>Recipient</th>
<th>Subject</th>
<th>Status</th>
<th>Error</th>
</tr>
</thead>
<tbody>
{% for email in sent_emails %}
<tr>
<td>{{ email.sent_at.strftime('%Y-%m-%d %H:%M') }}</td>
<td>{{ email.recipient }}</td>
<td>{{ email.subject }}</td>
<td>{{ email.status }}</td>
<td>{{ email.error or '' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}

View File

@@ -0,0 +1,603 @@
{% extends "admin/base.html" %}
{% block title %}Manage Chats - Admin{% endblock %}
{% block admin_content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pb-2 mb-3 border-bottom">
<h1 class="h2">
<i class="fas fa-comments me-2"></i>Manage Chat Rooms
</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<button type="button" class="btn btn-sm btn-outline-secondary me-2" onclick="location.reload()">
<i class="fas fa-sync-alt"></i> Refresh
</button>
<button type="button" class="btn btn-sm btn-primary" data-bs-toggle="modal" data-bs-target="#createRoomModal">
<i class="fas fa-plus"></i> Create Room
</button>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-primary h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs fw-bold text-primary text-uppercase mb-1">Total Rooms</div>
<div class="h5 mb-0 fw-bold text-gray-800">{{ total_rooms }}</div>
</div>
<div class="col-auto">
<i class="fas fa-comments fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-success h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs fw-bold text-success text-uppercase mb-1">Linked to Posts</div>
<div class="h5 mb-0 fw-bold text-gray-800">{{ linked_rooms }}</div>
</div>
<div class="col-auto">
<i class="fas fa-link fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-warning h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs fw-bold text-warning text-uppercase mb-1">Active Today</div>
<div class="h5 mb-0 fw-bold text-gray-800">{{ active_today }}</div>
</div>
<div class="col-auto">
<i class="fas fa-clock fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-info h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col me-2">
<div class="text-xs fw-bold text-info text-uppercase mb-1">Total Messages</div>
<div class="h5 mb-0 fw-bold text-gray-800">{{ total_messages }}</div>
</div>
<div class="col-auto">
<i class="fas fa-comment fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Filters and Search -->
<div class="card mb-4">
<div class="card-body">
<form method="GET" class="row g-3">
<div class="col-md-3">
<label for="category" class="form-label">Category</label>
<select class="form-select" id="category" name="category">
<option value="">All Categories</option>
<option value="general" {{ 'selected' if request.args.get('category') == 'general' }}>General</option>
<option value="technical" {{ 'selected' if request.args.get('category') == 'technical' }}>Technical</option>
<option value="maintenance" {{ 'selected' if request.args.get('category') == 'maintenance' }}>Maintenance</option>
<option value="routes" {{ 'selected' if request.args.get('category') == 'routes' }}>Routes</option>
<option value="events" {{ 'selected' if request.args.get('category') == 'events' }}>Events</option>
<option value="safety" {{ 'selected' if request.args.get('category') == 'safety' }}>Safety</option>
<option value="gear" {{ 'selected' if request.args.get('category') == 'gear' }}>Gear & Equipment</option>
<option value="social" {{ 'selected' if request.args.get('category') == 'social' }}>Social</option>
</select>
</div>
<div class="col-md-3">
<label for="status" class="form-label">Status</label>
<select class="form-select" id="status" name="status">
<option value="">All Status</option>
<option value="active" {{ 'selected' if request.args.get('status') == 'active' }}>Active</option>
<option value="inactive" {{ 'selected' if request.args.get('status') == 'inactive' }}>Inactive</option>
<option value="linked" {{ 'selected' if request.args.get('status') == 'linked' }}>Linked to Post</option>
<option value="unlinked" {{ 'selected' if request.args.get('status') == 'unlinked' }}>Not Linked</option>
</select>
</div>
<div class="col-md-4">
<label for="search" class="form-label">Search</label>
<input type="text" class="form-control" id="search" name="search"
placeholder="Search room name or description..."
value="{{ request.args.get('search', '') }}">
</div>
<div class="col-md-2">
<label class="form-label">&nbsp;</label>
<div class="d-grid">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search"></i> Filter
</button>
</div>
</div>
</form>
</div>
</div>
<!-- Chat Rooms Table -->
<div class="card">
<div class="card-header">
<h6 class="m-0 fw-bold text-primary">Chat Rooms</h6>
</div>
<div class="card-body">
{% if chat_rooms %}
<div class="table-responsive">
<table class="table table-bordered table-hover">
<thead class="table-light">
<tr>
<th>Room Name</th>
<th>Category</th>
<th>Created By</th>
<th>Linked Post</th>
<th>Messages</th>
<th>Last Activity</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for room in chat_rooms %}
<tr>
<td>
<div class="d-flex align-items-center">
<div>
<a href="{{ url_for('chat.room', room_id=room.id) }}"
class="fw-bold text-decoration-none" target="_blank">
{{ room.name }}
</a>
{% if room.description %}
<div class="small text-muted">{{ room.description[:100] }}{% if room.description|length > 100 %}...{% endif %}</div>
{% endif %}
</div>
</div>
</td>
<td>
<span class="badge bg-{{ 'success' if room.category == 'general' else 'info' if room.category == 'technical' else 'warning' if room.category == 'maintenance' else 'primary' }}">
{{ room.category.title() if room.category else 'Uncategorized' }}
</span>
</td>
<td>
<a href="{{ url_for('admin.user_detail', user_id=room.created_by.id) }}" class="text-decoration-none">
{{ room.created_by.nickname }}
</a>
</td>
<td>
{% if room.related_post %}
<a href="{{ url_for('admin.post_detail', post_id=room.related_post.id) }}"
class="text-decoration-none">
{{ room.related_post.title[:30] }}{% if room.related_post.title|length > 30 %}...{% endif %}
</a>
{% else %}
<span class="text-muted">Not linked</span>
{% endif %}
</td>
<td>
<span class="badge bg-secondary">{{ room.message_count or 0 }}</span>
</td>
<td>
{% if room.last_activity %}
<small>{{ room.last_activity.strftime('%Y-%m-%d %H:%M') }}</small>
{% else %}
<small class="text-muted">Never</small>
{% endif %}
</td>
<td>
<div class="btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle"
data-bs-toggle="dropdown">
Actions
</button>
<ul class="dropdown-menu">
<li>
<a class="dropdown-item" href="#"
onclick="editRoom({{ room.id }}, '{{ room.name }}', '{{ room.description or '' }}', '{{ room.category or '' }}', {{ room.related_post.id if room.related_post else 'null' }})">
<i class="fas fa-edit"></i> Edit Room
</a>
</li>
<li>
<a class="dropdown-item" href="#"
onclick="linkToPost({{ room.id }}, '{{ room.name }}')">
<i class="fas fa-link"></i> Link to Post
</a>
</li>
<li>
<a class="dropdown-item" href="#"
onclick="mergeRoom({{ room.id }}, '{{ room.name }}')">
<i class="fas fa-compress-arrows-alt"></i> Merge Room
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a class="dropdown-item text-danger" href="#"
onclick="deleteRoom({{ room.id }}, '{{ room.name }}')">
<i class="fas fa-trash"></i> Delete Room
</a>
</li>
</ul>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if pagination %}
<nav>
<ul class="pagination justify-content-center">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.manage_chats', page=pagination.prev_num, **request.args) }}">Previous</a>
</li>
{% endif %}
{% for page_num in pagination.iter_pages() %}
{% if page_num %}
{% if page_num != pagination.page %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.manage_chats', page=page_num, **request.args) }}">{{ page_num }}</a>
</li>
{% else %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<span class="page-link">...</span>
</li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.manage_chats', page=pagination.next_num, **request.args) }}">Next</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-4">
<i class="fas fa-comments fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No chat rooms found</h5>
<p class="text-muted">Create a new room or adjust your filters.</p>
</div>
{% endif %}
</div>
</div>
<!-- Edit Room Modal -->
<div class="modal fade" id="editRoomModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Edit Chat Room</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="editRoomForm">
<div class="modal-body">
<input type="hidden" id="editRoomId">
<div class="mb-3">
<label for="editRoomName" class="form-label">Room Name</label>
<input type="text" class="form-control" id="editRoomName" required>
</div>
<div class="mb-3">
<label for="editRoomDescription" class="form-label">Description</label>
<textarea class="form-control" id="editRoomDescription" rows="3"></textarea>
</div>
<div class="mb-3">
<label for="editRoomCategory" class="form-label">Category</label>
<select class="form-select" id="editRoomCategory">
<option value="">Select Category</option>
<option value="general">General</option>
<option value="technical">Technical</option>
<option value="maintenance">Maintenance</option>
<option value="routes">Routes</option>
<option value="events">Events</option>
<option value="safety">Safety</option>
<option value="gear">Gear & Equipment</option>
<option value="social">Social</option>
</select>
</div>
<div class="mb-3">
<label for="editLinkedPost" class="form-label">Linked Post</label>
<select class="form-select" id="editLinkedPost">
<option value="">No linked post</option>
{% for post in available_posts %}
<option value="{{ post.id }}">{{ post.title }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-primary">Save Changes</button>
</div>
</form>
</div>
</div>
</div>
<!-- Link to Post Modal -->
<div class="modal fade" id="linkPostModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Link Chat Room to Post</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="linkPostForm">
<div class="modal-body">
<input type="hidden" id="linkRoomId">
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
Linking a chat room to a post will make it appear in the post's discussion section.
</div>
<div class="mb-3">
<label for="linkRoomName" class="form-label">Room Name</label>
<input type="text" class="form-control" id="linkRoomName" readonly>
</div>
<div class="mb-3">
<label for="linkPostSelect" class="form-label">Select Post</label>
<select class="form-select" id="linkPostSelect" required>
<option value="">Choose a post...</option>
{% for post in available_posts %}
<option value="{{ post.id }}">{{ post.title }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-success">Link to Post</button>
</div>
</form>
</div>
</div>
</div>
<!-- Merge Room Modal -->
<div class="modal fade" id="mergeRoomModal" tabindex="-1">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Merge Chat Room</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form id="mergeRoomForm">
<div class="modal-body">
<input type="hidden" id="mergeSourceRoomId">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle"></i>
<strong>Warning:</strong> This action will merge all messages from the source room into the target room.
The source room will be deleted. This cannot be undone.
</div>
<div class="row">
<div class="col-md-6">
<h6>Source Room (will be deleted)</h6>
<div class="card bg-light">
<div class="card-body">
<h6 class="card-title" id="mergeSourceRoomName"></h6>
<p class="card-text small" id="mergeSourceRoomInfo"></p>
</div>
</div>
</div>
<div class="col-md-6">
<h6>Target Room (messages will be merged here)</h6>
<select class="form-select" id="mergeTargetRoom" required>
<option value="">Select target room...</option>
</select>
<div id="mergeTargetRoomPreview" class="mt-2" style="display: none;">
<div class="card bg-light">
<div class="card-body">
<h6 class="card-title" id="mergeTargetRoomName"></h6>
<p class="card-text small" id="mergeTargetRoomInfo"></p>
</div>
</div>
</div>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="submit" class="btn btn-danger">Merge Rooms</button>
</div>
</form>
</div>
</div>
</div>
<script>
// Edit room functionality
function editRoom(roomId, name, description, category, linkedPostId) {
document.getElementById('editRoomId').value = roomId;
document.getElementById('editRoomName').value = name;
document.getElementById('editRoomDescription').value = description;
document.getElementById('editRoomCategory').value = category;
document.getElementById('editLinkedPost').value = linkedPostId || '';
const modal = new bootstrap.Modal(document.getElementById('editRoomModal'));
modal.show();
}
// Link to post functionality
function linkToPost(roomId, roomName) {
document.getElementById('linkRoomId').value = roomId;
document.getElementById('linkRoomName').value = roomName;
const modal = new bootstrap.Modal(document.getElementById('linkPostModal'));
modal.show();
}
// Merge room functionality
function mergeRoom(roomId, roomName) {
document.getElementById('mergeSourceRoomId').value = roomId;
document.getElementById('mergeSourceRoomName').textContent = roomName;
// Load available rooms for merging
fetch(`/admin/api/chat-rooms?exclude=${roomId}`)
.then(response => response.json())
.then(data => {
const select = document.getElementById('mergeTargetRoom');
select.innerHTML = '<option value="">Select target room...</option>';
data.rooms.forEach(room => {
const option = document.createElement('option');
option.value = room.id;
option.textContent = `${room.name} (${room.category}) - ${room.message_count} messages`;
option.dataset.roomData = JSON.stringify(room);
select.appendChild(option);
});
});
const modal = new bootstrap.Modal(document.getElementById('mergeRoomModal'));
modal.show();
}
// Delete room functionality
function deleteRoom(roomId, roomName) {
if (confirm(`Are you sure you want to delete the room "${roomName}"? This action cannot be undone.`)) {
fetch(`/admin/api/chat-rooms/${roomId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error deleting room: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to delete room');
});
}
}
// Form submissions
document.getElementById('editRoomForm').addEventListener('submit', function(e) {
e.preventDefault();
const roomId = document.getElementById('editRoomId').value;
const formData = {
name: document.getElementById('editRoomName').value,
description: document.getElementById('editRoomDescription').value,
category: document.getElementById('editRoomCategory').value,
related_post_id: document.getElementById('editLinkedPost').value || null
};
fetch(`/admin/api/chat-rooms/${roomId}`, {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error updating room: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to update room');
});
});
document.getElementById('linkPostForm').addEventListener('submit', function(e) {
e.preventDefault();
const roomId = document.getElementById('linkRoomId').value;
const postId = document.getElementById('linkPostSelect').value;
fetch(`/admin/api/chat-rooms/${roomId}/link-post`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ post_id: postId })
})
.then(response => response.json())
.then(data => {
if (data.success) {
location.reload();
} else {
alert('Error linking room to post: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to link room to post');
});
});
document.getElementById('mergeRoomForm').addEventListener('submit', function(e) {
e.preventDefault();
const sourceRoomId = document.getElementById('mergeSourceRoomId').value;
const targetRoomId = document.getElementById('mergeTargetRoom').value;
fetch(`/admin/api/chat-rooms/${sourceRoomId}/merge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ target_room_id: targetRoomId })
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Rooms merged successfully!');
location.reload();
} else {
alert('Error merging rooms: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to merge rooms');
});
});
// Target room preview
document.getElementById('mergeTargetRoom').addEventListener('change', function() {
const selectedOption = this.options[this.selectedIndex];
const preview = document.getElementById('mergeTargetRoomPreview');
if (selectedOption.value && selectedOption.dataset.roomData) {
const roomData = JSON.parse(selectedOption.dataset.roomData);
document.getElementById('mergeTargetRoomName').textContent = roomData.name;
document.getElementById('mergeTargetRoomInfo').textContent =
`Category: ${roomData.category} | Messages: ${roomData.message_count}`;
preview.style.display = 'block';
} else {
preview.style.display = 'none';
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,269 @@
{% extends "admin/base.html" %}
{% block title %}Password Reset Email Template - Admin{% endblock %}
{% block admin_content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pb-2 mb-3 border-bottom">
<h1 class="h2">Password Reset Email Template</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<a href="{{ url_for('admin.password_reset_request_detail', request_id=token.request_id) }}"
class="btn btn-outline-secondary btn-sm me-2">
<i class="fas fa-arrow-left"></i> Back to Request
</a>
<a href="{{ url_for('admin.password_reset_tokens') }}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-list"></i> All Tokens
</a>
</div>
</div>
<!-- Token Status Alert -->
<div class="alert alert-info">
<h5 class="alert-heading">
<i class="fas fa-info-circle"></i> Token Information
</h5>
<p class="mb-2">
<strong>Token Status:</strong>
{% if token.is_used %}
<span class="badge bg-success">Used</span> - This token has already been used
{% elif token.is_expired %}
<span class="badge bg-secondary">Expired</span> - This token has expired
{% else %}
<span class="badge bg-warning">Active</span> - This token is ready to use
{% endif %}
</p>
<p class="mb-2">
<strong>Expires:</strong> {{ token.expires_at.strftime('%B %d, %Y at %I:%M %p') }}
</p>
<p class="mb-0">
<strong>For User:</strong> {{ token.user.nickname }} ({{ token.user.email }})
</p>
</div>
<!-- Email Template Card -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="m-0 fw-bold text-primary">Email Template - Copy and Send to User</h6>
<button type="button" class="btn btn-success btn-sm" onclick="copyEmailTemplate()">
<i class="fas fa-copy"></i> Copy All
</button>
</div>
<div class="card-body">
<!-- Email Subject -->
<div class="mb-4">
<label class="form-label fw-bold">Subject:</label>
<div class="input-group">
<input type="text" class="form-control" id="email-subject" readonly
value="Password Reset Request - Moto Adventure Website">
<button class="btn btn-outline-secondary" type="button" onclick="copyToClipboard('email-subject')">
<i class="fas fa-copy"></i>
</button>
</div>
</div>
<!-- Email Body -->
<div class="mb-4">
<label class="form-label fw-bold">Email Body:</label>
<div class="position-relative">
<textarea class="form-control" id="email-body" rows="12" readonly>Hello {{ token.user.nickname }},
We received your request for a password reset for your Moto Adventure website account.
To reset your password, please click the link below:
{{ reset_url }}
This link is valid for 24 hours and can only be used once. If you did not request this password reset, please ignore this email.
Important Security Information:
- This link expires on {{ token.expires_at.strftime('%B %d, %Y at %I:%M %p') }}
- Do not share this link with anyone
- If the link doesn't work, you may need to request a new password reset
If you have any questions or need assistance, please contact our support team.
Best regards,
Moto Adventure Team
---
This is an automated message. Please do not reply to this email.</textarea>
<button class="btn btn-outline-secondary position-absolute top-0 end-0 m-2"
type="button" onclick="copyToClipboard('email-body')">
<i class="fas fa-copy"></i> Copy
</button>
</div>
</div>
<!-- Reset Link Only -->
<div class="mb-4">
<label class="form-label fw-bold">Reset Link Only:</label>
<div class="input-group">
<input type="text" class="form-control" id="reset-link" readonly value="{{ reset_url }}">
<button class="btn btn-outline-secondary" type="button" onclick="copyToClipboard('reset-link')">
<i class="fas fa-copy"></i>
</button>
</div>
<small class="text-muted">Use this if you prefer to compose your own email message.</small>
</div>
</div>
</div>
<!-- Instructions Card -->
<div class="card mt-4">
<div class="card-header">
<h6 class="m-0 fw-bold text-primary">Instructions for Admin</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="fw-bold">How to Send:</h6>
<ol class="small">
<li>Copy the subject and email body above</li>
<li>Open your email client (Gmail, Outlook, etc.)</li>
<li>Create a new email to: <strong>{{ token.user.email }}</strong></li>
<li>Paste the subject and body</li>
<li>Send the email</li>
<li>Return here to monitor if the link was used</li>
</ol>
</div>
<div class="col-md-6">
<h6 class="fw-bold">Security Notes:</h6>
<ul class="small">
<li>Token expires in 24 hours automatically</li>
<li>Token can only be used once</li>
<li>Monitor token usage below</li>
<li>Do not share the reset link publicly</li>
<li>User must enter a new password to complete reset</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Token Usage Tracking -->
<div class="card mt-4">
<div class="card-header">
<h6 class="m-0 fw-bold text-primary">Token Usage Tracking</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-3">
<div class="text-center">
<div class="h4 mb-0 {{ 'text-success' if token.is_used else 'text-muted' }}">
{{ 'Yes' if token.is_used else 'No' }}
</div>
<small class="text-muted">Used</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<div class="h4 mb-0 {{ 'text-danger' if token.is_expired else 'text-success' }}">
{{ 'Yes' if token.is_expired else 'No' }}
</div>
<small class="text-muted">Expired</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<div class="h4 mb-0">
{% if token.used_at %}
{{ token.used_at.strftime('%m/%d %H:%M') }}
{% else %}
-
{% endif %}
</div>
<small class="text-muted">Used At</small>
</div>
</div>
<div class="col-md-3">
<div class="text-center">
<div class="h4 mb-0">
{% if token.user_ip %}
{{ token.user_ip }}
{% else %}
-
{% endif %}
</div>
<small class="text-muted">User IP</small>
</div>
</div>
</div>
<div class="mt-3">
<button type="button" class="btn btn-outline-secondary btn-sm" onclick="location.reload()">
<i class="fas fa-sync-alt"></i> Refresh Status
</button>
</div>
</div>
</div>
<script>
function copyToClipboard(elementId) {
const element = document.getElementById(elementId);
element.select();
element.setSelectionRange(0, 99999); // For mobile devices
try {
document.execCommand('copy');
showCopySuccess();
} catch (err) {
console.error('Failed to copy: ', err);
showCopyError();
}
}
function copyEmailTemplate() {
const subject = document.getElementById('email-subject').value;
const body = document.getElementById('email-body').value;
const combined = `Subject: ${subject}\n\n${body}`;
navigator.clipboard.writeText(combined).then(function() {
showCopySuccess();
}, function(err) {
console.error('Failed to copy: ', err);
showCopyError();
});
}
function showCopySuccess() {
// Create temporary success alert
const alert = document.createElement('div');
alert.className = 'alert alert-success alert-dismissible fade show position-fixed';
alert.style.top = '20px';
alert.style.right = '20px';
alert.style.zIndex = '9999';
alert.innerHTML = `
<i class="fas fa-check"></i> Copied to clipboard!
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alert);
// Auto-remove after 3 seconds
setTimeout(() => {
if (alert.parentNode) {
alert.parentNode.removeChild(alert);
}
}, 3000);
}
function showCopyError() {
// Create temporary error alert
const alert = document.createElement('div');
alert.className = 'alert alert-danger alert-dismissible fade show position-fixed';
alert.style.top = '20px';
alert.style.right = '20px';
alert.style.zIndex = '9999';
alert.innerHTML = `
<i class="fas fa-times"></i> Failed to copy. Please select and copy manually.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.body.appendChild(alert);
// Auto-remove after 5 seconds
setTimeout(() => {
if (alert.parentNode) {
alert.parentNode.removeChild(alert);
}
}, 5000);
}
</script>
{% endblock %}

View File

@@ -0,0 +1,242 @@
{% extends "admin/base.html" %}
{% block title %}Password Reset Request #{{ reset_request.id }} - Admin{% endblock %}
{% block admin_content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pb-2 mb-3 border-bottom">
<h1 class="h2">Password Reset Request #{{ reset_request.id }}</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<a href="{{ url_for('admin.password_reset_requests') }}" class="btn btn-outline-secondary btn-sm me-2">
<i class="fas fa-arrow-left"></i> Back to List
</a>
{% if reset_request.user and reset_request.status == 'pending' %}
<form method="POST" action="{{ url_for('admin.generate_password_reset_token', request_id=reset_request.id) }}"
class="d-inline" onsubmit="return confirm('Generate reset token for {{ reset_request.user_email }}?')">
<button type="submit" class="btn btn-success btn-sm">
<i class="fas fa-key"></i> Generate Reset Token
</button>
</form>
{% endif %}
</div>
</div>
<div class="row">
<!-- Request Information -->
<div class="col-lg-8">
<div class="card mb-4">
<div class="card-header">
<h6 class="m-0 fw-bold text-primary">Request Details</h6>
</div>
<div class="card-body">
<div class="row mb-3">
<div class="col-sm-3 fw-bold">Status:</div>
<div class="col-sm-9">
{% if reset_request.status == 'pending' %}
<span class="badge bg-warning fs-6">
<i class="fas fa-clock"></i> Pending
</span>
{% elif reset_request.status == 'token_generated' %}
<span class="badge bg-info fs-6">
<i class="fas fa-link"></i> Token Generated
</span>
{% elif reset_request.status == 'completed' %}
<span class="badge bg-success fs-6">
<i class="fas fa-check-circle"></i> Completed
</span>
{% elif reset_request.status == 'expired' %}
<span class="badge bg-secondary fs-6">
<i class="fas fa-calendar-times"></i> Expired
</span>
{% endif %}
</div>
</div>
<div class="row mb-3">
<div class="col-sm-3 fw-bold">User Email:</div>
<div class="col-sm-9">
<span class="fw-bold">{{ reset_request.user_email }}</span>
{% if reset_request.user %}
<br><small class="text-success">
<i class="fas fa-user-check"></i> User found: {{ reset_request.user.nickname }}
</small>
{% else %}
<br><small class="text-danger">
<i class="fas fa-user-times"></i> User not found in system
</small>
{% endif %}
</div>
</div>
<div class="row mb-3">
<div class="col-sm-3 fw-bold">Requested:</div>
<div class="col-sm-9">
{{ reset_request.created_at.strftime('%B %d, %Y at %I:%M %p') }}
<br><small class="text-muted">{{ reset_request.created_at.strftime('%A') }}</small>
</div>
</div>
<div class="row mb-3">
<div class="col-sm-3 fw-bold">Last Updated:</div>
<div class="col-sm-9">
{{ reset_request.updated_at.strftime('%B %d, %Y at %I:%M %p') }}
</div>
</div>
{% if reset_request.requester_message %}
<div class="row mb-3">
<div class="col-sm-3 fw-bold">Original Message:</div>
<div class="col-sm-9">
<div class="bg-light p-3 rounded">
{{ reset_request.requester_message }}
</div>
</div>
</div>
{% endif %}
{% if reset_request.chat_message %}
<div class="row mb-3">
<div class="col-sm-3 fw-bold">Chat Reference:</div>
<div class="col-sm-9">
<a href="{{ url_for('chat.room', room_id=reset_request.chat_message.room_id) }}"
class="btn btn-outline-info btn-sm">
<i class="fas fa-comments"></i> View in Chat
</a>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Admin Notes -->
<div class="card mb-4">
<div class="card-header">
<h6 class="m-0 fw-bold text-primary">Admin Notes</h6>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('admin.update_password_reset_notes', request_id=reset_request.id) }}">
<div class="mb-3">
<label for="admin_notes" class="form-label">Notes (visible only to admins):</label>
<textarea class="form-control" id="admin_notes" name="admin_notes" rows="4"
placeholder="Add notes about this password reset request...">{{ reset_request.admin_notes or '' }}</textarea>
</div>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Save Notes
</button>
</form>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Generated Tokens -->
<div class="card mb-4">
<div class="card-header">
<h6 class="m-0 fw-bold text-primary">Generated Tokens ({{ reset_request.tokens|length }})</h6>
</div>
<div class="card-body">
{% if reset_request.tokens %}
{% for token in reset_request.tokens %}
<div class="border rounded p-3 mb-3 {{ 'bg-light' if not token.is_valid else '' }}">
<div class="d-flex justify-content-between align-items-start mb-2">
<div>
{% if token.is_used %}
<span class="badge bg-success">Used</span>
{% elif token.is_expired %}
<span class="badge bg-secondary">Expired</span>
{% else %}
<span class="badge bg-warning">Active</span>
{% endif %}
</div>
<small class="text-muted">
{{ token.created_at.strftime('%m/%d %H:%M') }}
</small>
</div>
<div class="small mb-2">
<strong>Token:</strong> {{ token.token[:12] }}...
</div>
<div class="small mb-2">
<strong>Expires:</strong> {{ token.expires_at.strftime('%m/%d/%Y %H:%M') }}
</div>
{% if token.is_used %}
<div class="small mb-2">
<strong>Used:</strong> {{ token.used_at.strftime('%m/%d/%Y %H:%M') }}
</div>
{% endif %}
<div class="small mb-2">
<strong>Created by:</strong> {{ token.created_by_admin.nickname }}
</div>
{% if token.is_valid %}
<div class="mt-2">
<a href="{{ url_for('admin.password_reset_token_template', token_id=token.id) }}"
class="btn btn-outline-primary btn-sm">
<i class="fas fa-envelope"></i> Email Template
</a>
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="text-center text-muted py-4">
<i class="fas fa-key fa-2x mb-2"></i>
<p>No tokens generated yet.</p>
{% if reset_request.user and reset_request.status == 'pending' %}
<form method="POST" action="{{ url_for('admin.generate_password_reset_token', request_id=reset_request.id) }}"
onsubmit="return confirm('Generate reset token for {{ reset_request.user_email }}?')">
<button type="submit" class="btn btn-success btn-sm">
<i class="fas fa-key"></i> Generate Token
</button>
</form>
{% endif %}
</div>
{% endif %}
</div>
</div>
<!-- User Information -->
{% if reset_request.user %}
<div class="card">
<div class="card-header">
<h6 class="m-0 fw-bold text-primary">User Information</h6>
</div>
<div class="card-body">
<div class="mb-2">
<strong>Username:</strong> {{ reset_request.user.nickname }}
</div>
<div class="mb-2">
<strong>Email:</strong> {{ reset_request.user.email }}
</div>
<div class="mb-2">
<strong>Account Created:</strong> {{ reset_request.user.created_at.strftime('%m/%d/%Y') }}
</div>
<div class="mb-2">
<strong>Admin:</strong>
{% if reset_request.user.is_admin %}
<span class="badge bg-danger">Yes</span>
{% else %}
<span class="badge bg-secondary">No</span>
{% endif %}
</div>
<div class="mb-3">
<strong>Active:</strong>
{% if reset_request.user.is_active %}
<span class="badge bg-success">Yes</span>
{% else %}
<span class="badge bg-danger">No</span>
{% endif %}
</div>
<a href="{{ url_for('admin.user_detail', user_id=reset_request.user.id) }}"
class="btn btn-outline-primary btn-sm">
<i class="fas fa-user"></i> View User Profile
</a>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,230 @@
{% extends "admin/base.html" %}
{% block title %}Password Reset Requests - Admin{% endblock %}
{% block admin_content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pb-2 mb-3 border-bottom">
<h1 class="h2">Password Reset Requests</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<a href="{{ url_for('admin.password_reset_requests', status='all') }}"
class="btn btn-sm {{ 'btn-primary' if status == 'all' else 'btn-outline-secondary' }}">
All Requests
</a>
<a href="{{ url_for('admin.password_reset_requests', status='pending') }}"
class="btn btn-sm {{ 'btn-warning' if status == 'pending' else 'btn-outline-secondary' }}">
Pending
</a>
<a href="{{ url_for('admin.password_reset_requests', status='token_generated') }}"
class="btn btn-sm {{ 'btn-info' if status == 'token_generated' else 'btn-outline-secondary' }}">
Token Generated
</a>
<a href="{{ url_for('admin.password_reset_requests', status='completed') }}"
class="btn btn-sm {{ 'btn-success' if status == 'completed' else 'btn-outline-secondary' }}">
Completed
</a>
</div>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="location.reload()">
<i class="fas fa-sync-alt"></i> Refresh
</button>
</div>
</div>
{% if requests.items %}
<div class="card">
<div class="card-header">
<h6 class="m-0 fw-bold text-primary">
{{ requests.total }} Password Reset {{ 'Request' if requests.total == 1 else 'Requests' }}
{% if status != 'all' %}({{ status.replace('_', ' ').title() }}){% endif %}
</h6>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-striped table-hover mb-0">
<thead class="table-dark">
<tr>
<th>Request Date</th>
<th>User Email</th>
<th>User Found</th>
<th>Status</th>
<th>Generated Tokens</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for request in requests.items %}
<tr>
<td>
<div class="fw-bold">{{ request.created_at.strftime('%Y-%m-%d') }}</div>
<small class="text-muted">{{ request.created_at.strftime('%H:%M:%S') }}</small>
</td>
<td>
<div class="fw-bold">{{ request.user_email }}</div>
{% if request.user %}
<small class="text-success">
<i class="fas fa-user-check"></i> {{ request.user.nickname }}
</small>
{% endif %}
</td>
<td>
{% if request.user %}
<span class="badge bg-success">
<i class="fas fa-check"></i> Found
</span>
{% else %}
<span class="badge bg-danger">
<i class="fas fa-times"></i> Not Found
</span>
{% endif %}
</td>
<td>
{% if request.status == 'pending' %}
<span class="badge bg-warning">
<i class="fas fa-clock"></i> Pending
</span>
{% elif request.status == 'token_generated' %}
<span class="badge bg-info">
<i class="fas fa-link"></i> Token Generated
</span>
{% elif request.status == 'completed' %}
<span class="badge bg-success">
<i class="fas fa-check-circle"></i> Completed
</span>
{% elif request.status == 'expired' %}
<span class="badge bg-secondary">
<i class="fas fa-calendar-times"></i> Expired
</span>
{% endif %}
</td>
<td>
<div class="fw-bold">{{ request.tokens|length }}</div>
{% set active_tokens = request.tokens|selectattr('is_valid')|list %}
{% if active_tokens %}
<small class="text-success">{{ active_tokens|length }} active</small>
{% else %}
<small class="text-muted">None active</small>
{% endif %}
</td>
<td>
<div class="btn-group btn-group-sm">
<a href="{{ url_for('admin.password_reset_request_detail', request_id=request.id) }}"
class="btn btn-outline-primary" title="View Details">
<i class="fas fa-eye"></i>
</a>
{% if request.user and request.status == 'pending' %}
<form method="POST" action="{{ url_for('admin.generate_password_reset_token', request_id=request.id) }}"
class="d-inline" onsubmit="return confirm('Generate reset token for {{ request.user_email }}?')">
<button type="submit" class="btn btn-outline-success" title="Generate Token">
<i class="fas fa-key"></i>
</button>
</form>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Pagination -->
{% if requests.pages > 1 %}
<nav aria-label="Page navigation" class="mt-3">
<ul class="pagination justify-content-center">
{% if requests.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.password_reset_requests', page=requests.prev_num, status=status) }}">
Previous
</a>
</li>
{% endif %}
{% for page_num in requests.iter_pages() %}
{% if page_num %}
{% if page_num != requests.page %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.password_reset_requests', page=page_num, status=status) }}">
{{ page_num }}
</a>
</li>
{% else %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<span class="page-link"></span>
</li>
{% endif %}
{% endfor %}
{% if requests.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.password_reset_requests', page=requests.next_num, status=status) }}">
Next
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="card">
<div class="card-body text-center py-5">
<i class="fas fa-key fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No Password Reset Requests</h5>
<p class="text-muted">
{% if status == 'all' %}
No password reset requests have been made yet.
{% else %}
No {{ status.replace('_', ' ') }} password reset requests found.
{% endif %}
</p>
{% if status != 'all' %}
<a href="{{ url_for('admin.password_reset_requests', status='all') }}" class="btn btn-primary">
View All Requests
</a>
{% endif %}
</div>
</div>
{% endif %}
<!-- Help Information -->
<div class="row mt-4">
<div class="col-12">
<div class="card border-info">
<div class="card-header bg-info text-white">
<h6 class="m-0"><i class="fas fa-info-circle"></i> How Password Reset Works</h6>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="fw-bold">Process Flow:</h6>
<ol class="small">
<li>User requests password reset through chat system</li>
<li>Request appears here with "Pending" status</li>
<li>Admin generates one-time reset token (24h expiry)</li>
<li>Admin copies email template and sends to user</li>
<li>User clicks link and resets password</li>
<li>Token becomes "Used" and request "Completed"</li>
</ol>
</div>
<div class="col-md-6">
<h6 class="fw-bold">Status Meanings:</h6>
<ul class="small">
<li><span class="badge bg-warning">Pending</span> - Awaiting admin action</li>
<li><span class="badge bg-info">Token Generated</span> - Reset link created</li>
<li><span class="badge bg-success">Completed</span> - Password successfully reset</li>
<li><span class="badge bg-secondary">Expired</span> - Token expired unused</li>
</ul>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,324 @@
{% extends "admin/base.html" %}
{% block title %}Password Reset Tokens - Admin{% endblock %}
{% block admin_content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pb-2 mb-3 border-bottom">
<h1 class="h2">Password Reset Tokens</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<a href="{{ url_for('admin.password_reset_requests') }}" class="btn btn-outline-secondary btn-sm me-2">
<i class="fas fa-list"></i> View Requests
</a>
<a href="{{ url_for('admin.dashboard') }}" class="btn btn-outline-secondary btn-sm">
<i class="fas fa-tachometer-alt"></i> Dashboard
</a>
</div>
</div>
<!-- Statistics Cards -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card border-left-primary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
Total Tokens
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ tokens.total }}</div>
</div>
<div class="col-auto">
<i class="fas fa-key fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-left-warning shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
Active Tokens
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ active_count }}</div>
</div>
<div class="col-auto">
<i class="fas fa-clock fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-left-success shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
Used Tokens
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ used_count }}</div>
</div>
<div class="col-auto">
<i class="fas fa-check fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card border-left-secondary shadow h-100 py-2">
<div class="card-body">
<div class="row no-gutters align-items-center">
<div class="col mr-2">
<div class="text-xs font-weight-bold text-secondary text-uppercase mb-1">
Expired Tokens
</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ expired_count }}</div>
</div>
<div class="col-auto">
<i class="fas fa-times fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Filters -->
<div class="card mb-4">
<div class="card-header">
<h6 class="m-0 fw-bold text-primary">Filter Tokens</h6>
</div>
<div class="card-body">
<form method="GET" action="{{ url_for('admin.password_reset_tokens') }}">
<div class="row">
<div class="col-md-3">
<label for="status" class="form-label">Status</label>
<select class="form-select" id="status" name="status">
<option value="">All Statuses</option>
<option value="active" {{ 'selected' if request.args.get('status') == 'active' }}>Active</option>
<option value="used" {{ 'selected' if request.args.get('status') == 'used' }}>Used</option>
<option value="expired" {{ 'selected' if request.args.get('status') == 'expired' }}>Expired</option>
</select>
</div>
<div class="col-md-3">
<label for="user_email" class="form-label">User Email</label>
<input type="email" class="form-control" id="user_email" name="user_email"
value="{{ request.args.get('user_email', '') }}" placeholder="user@example.com">
</div>
<div class="col-md-3">
<label for="date_from" class="form-label">Created From</label>
<input type="date" class="form-control" id="date_from" name="date_from"
value="{{ request.args.get('date_from', '') }}">
</div>
<div class="col-md-3">
<label for="date_to" class="form-label">Created To</label>
<input type="date" class="form-control" id="date_to" name="date_to"
value="{{ request.args.get('date_to', '') }}">
</div>
</div>
<div class="row mt-3">
<div class="col-12">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search"></i> Filter
</button>
<a href="{{ url_for('admin.password_reset_tokens') }}" class="btn btn-secondary">
<i class="fas fa-undo"></i> Clear
</a>
</div>
</div>
</form>
</div>
</div>
<!-- Tokens Table -->
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="m-0 fw-bold text-primary">Password Reset Tokens</h6>
<span class="text-muted">{{ tokens.total }} total tokens</span>
</div>
<div class="card-body">
{% if tokens.items %}
<div class="table-responsive">
<table class="table table-bordered table-hover">
<thead class="table-light">
<tr>
<th>User</th>
<th>Status</th>
<th>Created</th>
<th>Expires</th>
<th>Used</th>
<th>Admin</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for token in tokens.items %}
<tr>
<td>
<div class="d-flex align-items-center">
<div>
<div class="fw-bold">{{ token.user.nickname }}</div>
<div class="text-muted small">{{ token.user.email }}</div>
</div>
</div>
</td>
<td>
{% if token.is_used %}
<span class="badge bg-success">Used</span>
{% elif token.is_expired %}
<span class="badge bg-secondary">Expired</span>
{% else %}
<span class="badge bg-warning">Active</span>
{% endif %}
</td>
<td>
<div>{{ token.created_at.strftime('%m/%d/%Y') }}</div>
<small class="text-muted">{{ token.created_at.strftime('%I:%M %p') }}</small>
</td>
<td>
<div>{{ token.expires_at.strftime('%m/%d/%Y') }}</div>
<small class="text-muted">{{ token.expires_at.strftime('%I:%M %p') }}</small>
</td>
<td>
{% if token.used_at %}
<div>{{ token.used_at.strftime('%m/%d/%Y') }}</div>
<small class="text-muted">{{ token.used_at.strftime('%I:%M %p') }}</small>
{% if token.user_ip %}
<br><small class="text-muted">IP: {{ token.user_ip }}</small>
{% endif %}
{% else %}
<span class="text-muted">-</span>
{% endif %}
</td>
<td>
<div class="fw-bold">{{ token.created_by.nickname }}</div>
<small class="text-muted">{{ token.created_by.email }}</small>
</td>
<td>
<div class="btn-group" role="group">
{% if not token.is_used and not token.is_expired %}
<a href="{{ url_for('admin.password_reset_token_template', token_id=token.id) }}"
class="btn btn-sm btn-outline-primary" title="Copy Email Template">
<i class="fas fa-envelope"></i>
</a>
{% endif %}
<a href="{{ url_for('admin.password_reset_request_detail', request_id=token.request_id) }}"
class="btn btn-sm btn-outline-secondary" title="View Request">
<i class="fas fa-eye"></i>
</a>
{% if not token.is_used and not token.is_expired %}
<button type="button" class="btn btn-sm btn-outline-danger"
onclick="confirmExpireToken('{{ token.id }}')" title="Expire Token">
<i class="fas fa-ban"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination -->
{% if tokens.pages > 1 %}
<nav aria-label="Tokens pagination">
<ul class="pagination justify-content-center">
<li class="page-item {{ 'disabled' if not tokens.has_prev }}">
<a class="page-link" href="{{ url_for('admin.password_reset_tokens', page=tokens.prev_num, **request.args) }}">Previous</a>
</li>
{% for page_num in tokens.iter_pages() %}
{% if page_num %}
{% if page_num != tokens.page %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.password_reset_tokens', page=page_num, **request.args) }}">{{ page_num }}</a>
</li>
{% else %}
<li class="page-item active">
<span class="page-link">{{ page_num }}</span>
</li>
{% endif %}
{% else %}
<li class="page-item disabled">
<span class="page-link">...</span>
</li>
{% endif %}
{% endfor %}
<li class="page-item {{ 'disabled' if not tokens.has_next }}">
<a class="page-link" href="{{ url_for('admin.password_reset_tokens', page=tokens.next_num, **request.args) }}">Next</a>
</li>
</ul>
</nav>
{% endif %}
{% else %}
<div class="text-center py-4">
<i class="fas fa-key fa-3x text-muted mb-3"></i>
<h5>No Tokens Found</h5>
<p class="text-muted">No password reset tokens match your current filters.</p>
<a href="{{ url_for('admin.password_reset_requests') }}" class="btn btn-primary">
<i class="fas fa-plus"></i> Generate New Token
</a>
</div>
{% endif %}
</div>
</div>
<!-- Expire Token Modal -->
<div class="modal fade" id="expireTokenModal" tabindex="-1">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Expire Token</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to expire this password reset token?</p>
<p class="text-muted small">This action cannot be undone. The user will not be able to use this token to reset their password.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" onclick="expireToken()">Expire Token</button>
</div>
</div>
</div>
</div>
<script>
let tokenToExpire = null;
function confirmExpireToken(tokenId) {
tokenToExpire = tokenId;
const modal = new bootstrap.Modal(document.getElementById('expireTokenModal'));
modal.show();
}
function expireToken() {
if (tokenToExpire) {
// Create form and submit
const form = document.createElement('form');
form.method = 'POST';
form.action = `/admin/password-reset-tokens/${tokenToExpire}/expire`;
// Add CSRF token if available
const csrfToken = document.querySelector('meta[name=csrf-token]');
if (csrfToken) {
const csrfInput = document.createElement('input');
csrfInput.type = 'hidden';
csrfInput.name = 'csrf_token';
csrfInput.value = csrfToken.getAttribute('content');
form.appendChild(csrfInput);
}
document.body.appendChild(form);
form.submit();
}
}
</script>
{% endblock %}

View File

@@ -208,7 +208,7 @@
</div>
<div class="card-body">
{% if post.published %}
<a href="{{ url_for('community.post_detail', post_id=post.id) }}"
<a href="{{ url_for('community.post_detail', id=post.id) }}"
class="btn btn-sm btn-primary w-100 mb-2" target="_blank">
<i class="fas fa-external-link-alt"></i> View on Site
</a>

View File

@@ -60,6 +60,9 @@
{% if post.subtitle %}
<br><small class="text-muted">{{ post.subtitle[:80] }}{% if post.subtitle|length > 80 %}...{% endif %}</small>
{% endif %}
{% if post.gpx_files.count() > 0 %}
<!-- Map iframe removed as requested -->
{% endif %}
</div>
</td>
<td>

View File

@@ -17,6 +17,19 @@
<div class="row">
<!-- User Information -->
<div class="col-lg-4">
<!-- Admin: Reset Password Card -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-danger">Admin: Reset Password</h6>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('admin.reset_user_password', user_id=user.id) }}">
<button type="submit" class="btn btn-warning w-100" onclick="return confirm('Send password reset email to this user?')">
<i class="fas fa-envelope"></i> Send Password Reset Email
</button>
</form>
</div>
</div>
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">User Information</h6>

View File

@@ -0,0 +1,67 @@
{% extends "base.html" %}
{% block title %}Password Reset Request{% endblock %}
{% block content %}
<div class="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-teal-900 py-20">
<div class="max-w-md mx-auto">
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="bg-gradient-to-r from-orange-600 to-red-600 p-8 text-white text-center">
<i class="fas fa-key text-4xl mb-4"></i>
<h2 class="text-2xl font-bold">Password Reset Request</h2>
<p class="text-orange-100 mt-2">We'll help you get back into your account</p>
</div>
<div class="p-8">
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="flex items-start">
<i class="fas fa-info-circle text-blue-500 mr-3 mt-1"></i>
<div class="text-sm text-blue-700">
<p class="font-semibold mb-1">How it works:</p>
<p>Enter your email address and we'll send a password reset request to our administrators. They will contact you directly to help reset your password securely.</p>
</div>
</div>
</div>
<form method="POST" class="space-y-6">
{{ form.hidden_tag() }}
<div>
{{ form.email.label(class="block text-sm font-semibold text-gray-700 mb-2") }}
<div class="relative">
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
<i class="fas fa-envelope text-gray-400"></i>
</div>
{{ form.email(class="w-full pl-10 pr-3 py-3 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-orange-500 focus:border-transparent transition-all duration-200", placeholder="Enter your email address") }}
</div>
{% if form.email.errors %}
<div class="text-red-500 text-sm mt-2">
{% for error in form.email.errors %}
<p><i class="fas fa-exclamation-circle mr-1"></i>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
{{ form.submit(class="w-full bg-gradient-to-r from-orange-600 to-red-600 text-white py-3 px-6 rounded-lg hover:from-orange-700 hover:to-red-700 focus:outline-none focus:ring-2 focus:ring-orange-500 focus:ring-offset-2 transition-all duration-200 font-semibold text-lg") }}
</form>
<div class="mt-8 pt-6 border-t border-gray-200 text-center">
<p class="text-sm text-gray-600 mb-4">Remember your password?</p>
<a href="{{ url_for('auth.login') }}"
class="inline-flex items-center text-blue-600 hover:text-blue-700 font-medium transition-colors duration-200">
<i class="fas fa-arrow-left mr-2"></i>Back to Login
</a>
</div>
<div class="mt-6 bg-green-50 border border-green-200 rounded-lg p-4">
<div class="flex items-start">
<i class="fas fa-shield-alt text-green-500 mr-3 mt-1"></i>
<div class="text-sm text-green-700">
<p class="font-semibold mb-1">Security Note:</p>
<p>Our administrators will verify your identity before resetting your password to keep your account secure.</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,38 @@
{% extends "base.html" %}
{% block title %}Reset Password{% endblock %}
{% block content %}
<div class="min-h-screen bg-gray-50 py-20">
<div class="max-w-md mx-auto bg-white rounded-lg shadow-lg overflow-hidden">
<div class="bg-gradient-to-r from-blue-500 via-purple-500 to-teal-500 p-6 text-white text-center">
<h2 class="text-2xl font-bold">Reset your password</h2>
<p class="text-blue-100 mt-1">Enter your new password below.</p>
</div>
<form method="POST" class="p-6 space-y-6">
{{ form.hidden_tag() }}
<div>
{{ form.password.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.password(class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent") }}
{% if form.password.errors %}
<div class="text-red-500 text-sm mt-1">
{% for error in form.password.errors %}
<p>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
<div>
{{ form.confirm_password.label(class="block text-sm font-medium text-gray-700 mb-1") }}
{{ form.confirm_password(class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-purple-500 focus:border-transparent") }}
{% if form.confirm_password.errors %}
<div class="text-red-500 text-sm mt-1">
{% for error in form.confirm_password.errors %}
<p>{{ error }}</p>
{% endfor %}
</div>
{% endif %}
</div>
{{ form.submit(class="w-full bg-gradient-to-r from-blue-600 to-purple-600 text-white py-2 px-4 rounded-lg hover:from-blue-700 hover:to-purple-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 transition font-medium") }}
</form>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,182 @@
{% extends "base.html" %}
{% block title %}Reset Your Password - Moto Adventure{% endblock %}
{% block content %}
<div class="auth-container">
<div class="auth-card">
<div class="auth-header">
<img src="{{ url_for('static', filename='images/logo.png') }}" alt="Moto Adventure" class="auth-logo">
<h2>Reset Your Password</h2>
<p class="text-muted">Enter your new password below</p>
</div>
<div class="auth-body">
{% with messages = get_flashed_messages(with_categories=true) %}
{% if messages %}
{% for category, message in messages %}
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
{{ message }}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
<!-- User Info -->
<div class="alert alert-info">
<i class="fas fa-user"></i>
Resetting password for: <strong>{{ user.nickname }}</strong> ({{ user.email }})
</div>
<form method="POST">
{{ form.hidden_tag() }}
<div class="form-group mb-3">
{{ form.password.label(class="form-label") }}
{{ form.password(class="form-control") }}
{% if form.password.errors %}
<div class="text-danger small mt-1">
{% for error in form.password.errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
<div class="form-text">
Password must be at least 8 characters long and contain both letters and numbers.
</div>
</div>
<div class="form-group mb-4">
{{ form.password2.label(class="form-label") }}
{{ form.password2(class="form-control") }}
{% if form.password2.errors %}
<div class="text-danger small mt-1">
{% for error in form.password2.errors %}
<div>{{ error }}</div>
{% endfor %}
</div>
{% endif %}
</div>
<div class="d-grid">
{{ form.submit(class="btn btn-primary btn-lg") }}
</div>
</form>
</div>
<div class="auth-footer">
<div class="text-center">
<a href="{{ url_for('auth.login') }}" class="text-decoration-none">
<i class="fas fa-arrow-left"></i> Back to Login
</a>
</div>
</div>
</div>
</div>
<!-- Security Features -->
<div class="container mt-4">
<div class="row justify-content-center">
<div class="col-md-6">
<div class="card border-warning">
<div class="card-body text-center">
<h6 class="card-title text-warning">
<i class="fas fa-shield-alt"></i> Security Notice
</h6>
<p class="card-text small text-muted mb-0">
This reset link can only be used once and will expire soon.
After resetting your password, you'll be able to log in immediately.
</p>
</div>
</div>
</div>
</div>
</div>
<style>
.auth-container {
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 20px;
}
.auth-card {
background: white;
border-radius: 15px;
box-shadow: 0 15px 35px rgba(0, 0, 0, 0.1);
width: 100%;
max-width: 400px;
overflow: hidden;
}
.auth-header {
text-align: center;
padding: 40px 30px 30px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.auth-logo {
width: 60px;
height: 60px;
margin-bottom: 20px;
border-radius: 50%;
border: 3px solid white;
}
.auth-header h2 {
margin-bottom: 10px;
font-weight: 600;
}
.auth-body {
padding: 30px;
}
.auth-footer {
padding: 20px 30px;
background-color: #f8f9fa;
text-align: center;
}
.form-control {
border-radius: 8px;
border: 2px solid #e9ecef;
padding: 12px 15px;
transition: all 0.3s ease;
}
.form-control:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 8px;
padding: 12px;
font-weight: 600;
transition: all 0.3s ease;
}
.btn-primary:hover {
transform: translateY(-1px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
}
@media (max-width: 576px) {
.auth-container {
padding: 10px;
}
.auth-header, .auth-body {
padding: 20px;
}
}
</style>
{% endblock %}

View File

@@ -6,8 +6,6 @@
<title>{% block title %}Moto Adventure Community{% endblock %}</title>
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
<script src="https://cdn.tailwindcss.com"></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-50">
@@ -22,13 +20,17 @@
</a>
</div>
<div class="hidden md:flex items-center space-x-8">
<a href="{{ url_for('main.index') }}" class="text-white hover:text-blue-200 transition">Landing</a>
<a href="{{ url_for('community.index') }}" class="text-white hover:text-teal-200 transition font-semibold">🏍️ Adventures</a>
<a href="{{ url_for('chat.index') }}" class="text-white hover:text-purple-200 transition">
<i class="fas fa-comments mr-1"></i>Chat
</a>
<a href="{{ url_for('main.index') }}#accommodation" class="text-white hover:text-purple-200 transition">Accommodation</a>
{% if current_user.is_authenticated %}
<a href="{{ url_for('community.new_post') }}" class="bg-gradient-to-r from-green-500 to-emerald-500 text-white px-4 py-2 rounded-lg hover:from-green-400 hover:to-emerald-400 transition transform hover:scale-105">
<i class="fas fa-plus mr-2"></i>Share Adventure
</a>
{% if not current_user.is_admin %}
<a href="{{ url_for('community.new_post') }}" class="bg-gradient-to-r from-green-500 to-emerald-500 text-white px-4 py-2 rounded-lg hover:from-green-400 hover:to-emerald-400 transition transform hover:scale-105">
<i class="fas fa-plus mr-2"></i>Share Adventure
</a>
{% endif %}
<a href="{{ url_for('community.profile') }}" class="text-white hover:text-blue-200 transition">
<i class="fas fa-user mr-1"></i>My Profile
</a>
@@ -60,10 +62,15 @@
<a href="{{ url_for('main.index') }}#about" class="text-white block px-3 py-2 hover:bg-blue-600 rounded">About</a>
<a href="{{ url_for('main.index') }}#accommodation" class="text-white block px-3 py-2 hover:bg-purple-600 rounded">Accommodation</a>
<a href="{{ url_for('community.index') }}" class="text-white block px-3 py-2 hover:bg-teal-600 rounded">Stories & Tracks</a>
<a href="{{ url_for('chat.index') }}" class="text-white block px-3 py-2 hover:bg-purple-600 rounded">
<i class="fas fa-comments mr-2"></i>Chat
</a>
{% if current_user.is_authenticated %}
<a href="{{ url_for('community.new_post') }}" class="text-white block px-3 py-2 hover:bg-green-600 rounded">
<i class="fas fa-plus mr-2"></i>New Post
</a>
{% if not current_user.is_admin %}
<a href="{{ url_for('community.new_post') }}" class="text-white block px-3 py-2 hover:bg-green-600 rounded">
<i class="fas fa-plus mr-2"></i>New Post
</a>
{% endif %}
<a href="{{ url_for('community.profile') }}" class="text-white block px-3 py-2 hover:bg-blue-600 rounded">
<i class="fas fa-user mr-1"></i>My Profile
</a>
@@ -175,5 +182,7 @@
});
});
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

@@ -0,0 +1,243 @@
{% extends "base.html" %}
{% block title %}Create Chat Room{% endblock %}
{% block content %}
<div class="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-teal-900 pt-16">
<!-- Header Section -->
<div class="relative overflow-hidden py-12">
<div class="absolute inset-0 bg-black/20"></div>
<div class="relative max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
<h1 class="text-3xl font-bold text-white mb-2">
<i class="fas fa-plus-circle mr-3"></i>Create New Chat Room
</h1>
<p class="text-blue-200">Start a discussion with the motorcycle community</p>
</div>
</div>
</div>
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 pb-12 -mt-6">
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="bg-gradient-to-r from-green-600 to-emerald-600 p-6">
<div class="flex items-center justify-between text-white">
<div class="flex items-center">
<i class="fas fa-comments text-2xl mr-3"></i>
<div>
<h2 class="text-xl font-bold">Room Configuration</h2>
<p class="text-green-100 text-sm">Set up your chat room details</p>
</div>
</div>
<a href="{{ url_for('chat.index') }}" class="bg-white/20 hover:bg-white/30 px-4 py-2 rounded-lg transition-all duration-200">
<i class="fas fa-arrow-left mr-2"></i>Back to Chat
</a>
</div>
</div>
<div class="p-8">
<form method="POST" action="{{ url_for('chat.create_room') }}" class="space-y-6">
<!-- Room Name -->
<div class="space-y-2">
<label for="room_name" class="block text-sm font-semibold text-gray-700">
<i class="fas fa-tag mr-2 text-green-600"></i>Room Name *
</label>
<input type="text" class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-green-500 focus:border-transparent transition-all duration-200"
id="room_name" name="room_name"
placeholder="Enter a descriptive room name" required maxlength="100">
<p class="text-xs text-gray-500">Choose a clear, descriptive name for your chat room</p>
</div>
<!-- Description -->
<div class="space-y-2">
<label for="description" class="block text-sm font-semibold text-gray-700">
<i class="fas fa-align-left mr-2 text-green-600"></i>Description
</label>
<textarea class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-green-500 focus:border-transparent transition-all duration-200"
id="description" name="description" rows="3"
placeholder="Describe what this room is about..." maxlength="500"></textarea>
<p class="text-xs text-gray-500">Optional: Help others understand the room's purpose</p>
</div>
<!-- Post Binding Section -->
<div class="space-y-4 p-6 bg-blue-50 rounded-xl border border-blue-200">
<div class="flex items-center">
<i class="fas fa-link mr-3 text-blue-600 text-lg"></i>
<h3 class="text-lg font-semibold text-gray-800">Link to Post (Optional)</h3>
</div>
<div class="space-y-2">
<label for="related_post_id" class="block text-sm font-semibold text-gray-700">
Select a post to discuss
</label>
<select class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-blue-500 focus:border-transparent transition-all duration-200"
id="related_post_id" name="related_post_id">
<option value="">No specific post - General discussion</option>
{% for post in posts %}
<option value="{{ post.id }}" {% if pre_selected_post and post.id == pre_selected_post %}selected{% endif %}>
{{ post.title }} - by {{ post.author.nickname }}
</option>
{% endfor %}
</select>
<p class="text-xs text-gray-500">
<i class="fas fa-info-circle mr-1"></i>
Link this room to a specific post for focused discussions
</p>
</div>
</div>
<!-- Room Type -->
<div class="space-y-2">
<label for="room_type" class="block text-sm font-semibold text-gray-700">
<i class="fas fa-folder mr-2 text-green-600"></i>Room Category
</label>
<select class="w-full px-4 py-3 border border-gray-300 rounded-xl focus:ring-2 focus:ring-green-500 focus:border-transparent transition-all duration-200"
id="room_type" name="room_type">
<option value="general">General Discussion</option>
<option value="technical">Technical Support</option>
<option value="social">Social Chat</option>
<option value="post_discussion">Post Discussion</option>
</select>
<p class="text-xs text-gray-500">Category will auto-update based on post selection</p>
</div>
<!-- Privacy Setting -->
<div class="space-y-3 p-6 bg-amber-50 rounded-xl border border-amber-200">
<div class="flex items-center">
<i class="fas fa-shield-alt mr-3 text-amber-600 text-lg"></i>
<h3 class="text-lg font-semibold text-gray-800">Privacy Settings</h3>
</div>
<div class="flex items-start space-x-3">
<input class="mt-1 w-4 h-4 text-amber-600 border-gray-300 rounded focus:ring-amber-500"
type="checkbox" id="is_private" name="is_private">
<div>
<label class="block text-sm font-medium text-gray-700" for="is_private">
Make this room private
</label>
<p class="text-xs text-gray-500 mt-1">
Private rooms are only visible to invited members. Public rooms can be joined by anyone.
</p>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="flex flex-col sm:flex-row gap-4 pt-6">
<a href="{{ url_for('chat.index') }}"
class="flex-1 px-6 py-3 bg-gray-100 text-gray-700 font-semibold rounded-xl hover:bg-gray-200 transition-all duration-200 text-center">
<i class="fas fa-times mr-2"></i>Cancel
</a>
<button type="submit"
class="flex-1 px-6 py-3 bg-gradient-to-r from-green-600 to-emerald-600 text-white font-semibold rounded-xl hover:from-green-700 hover:to-emerald-700 transition-all duration-200">
<i class="fas fa-plus mr-2"></i>Create Chat Room
</button>
</div>
</form>
</div>
</div>
<!-- Recent Posts Preview -->
{% if posts %}
<div class="mt-8 bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="bg-gradient-to-r from-blue-600 to-purple-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-newspaper text-2xl mr-3"></i>
<div>
<h3 class="text-xl font-bold">Recent Community Posts</h3>
<p class="text-blue-100 text-sm">Available for discussion rooms</p>
</div>
</div>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{% for post in posts[:6] %}
<div class="bg-gray-50 rounded-xl p-4 hover:bg-gray-100 transition-all duration-200 cursor-pointer post-preview"
data-post-id="{{ post.id }}" data-post-title="{{ post.title }}">
<h4 class="font-semibold text-gray-800 mb-2 line-clamp-2">
{{ post.title }}
</h4>
<p class="text-gray-600 text-sm mb-3 line-clamp-3">
{{ post.content[:120] }}{% if post.content|length > 120 %}...{% endif %}
</p>
<div class="flex items-center justify-between text-xs text-gray-500">
<span>by {{ post.author.nickname }}</span>
<span>{{ post.created_at.strftime('%m/%d') }}</span>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
</div>
</div>
<script>
// Auto-update room type when post is selected
document.getElementById('related_post_id').addEventListener('change', function() {
const roomTypeSelect = document.getElementById('room_type');
if (this.value) {
roomTypeSelect.value = 'post_discussion';
document.getElementById('room_name').placeholder = 'Discussion: ' + this.options[this.selectedIndex].text.split(' - ')[0];
} else {
roomTypeSelect.value = 'general';
document.getElementById('room_name').placeholder = 'Enter a descriptive room name';
}
});
// Post preview selection
document.querySelectorAll('.post-preview').forEach(preview => {
preview.addEventListener('click', function() {
const postId = this.dataset.postId;
const postTitle = this.dataset.postTitle;
// Update the select dropdown
document.getElementById('related_post_id').value = postId;
// Update room name suggestion
document.getElementById('room_name').value = `Discussion: ${postTitle}`;
// Update room type
document.getElementById('room_type').value = 'post_discussion';
// Visual feedback
document.querySelectorAll('.post-preview').forEach(p => p.classList.remove('ring-2', 'ring-blue-500', 'bg-blue-100'));
this.classList.add('ring-2', 'ring-blue-500', 'bg-blue-100');
// Scroll to form
document.querySelector('form').scrollIntoView({ behavior: 'smooth' });
});
});
// Form validation
document.querySelector('form').addEventListener('submit', function(e) {
const roomName = document.getElementById('room_name').value.trim();
if (!roomName) {
e.preventDefault();
alert('Please enter a room name');
document.getElementById('room_name').focus();
}
});
</script>
<style>
.line-clamp-2 {
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.line-clamp-3 {
display: -webkit-box;
-webkit-line-clamp: 3;
-webkit-box-orient: vertical;
overflow: hidden;
}
.post-preview:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
</style>
{% endblock %}

View File

@@ -0,0 +1,313 @@
<!-- Embeddable Chat Widget for Posts and Pages -->
<div class="chat-embed-widget" data-post-id="{{ post.id if post else '' }}" style="margin: 1rem 0;">
<div class="chat-embed-header" onclick="toggleChatEmbed(this)">
<div class="d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center">
<i class="fas fa-comments me-2"></i>
<span class="chat-embed-title">
{% if post %}
Discussion: {{ post.title[:50] }}{% if post.title|length > 50 %}...{% endif %}
{% else %}
Join the Discussion
{% endif %}
</span>
<span class="badge bg-primary ms-2" id="messageCount-{{ post.id if post else 'general' }}">
{{ message_count or 0 }} messages
</span>
</div>
<i class="fas fa-chevron-down chat-embed-toggle"></i>
</div>
</div>
<div class="chat-embed-content" style="display: none;">
<div class="chat-embed-messages" id="embedMessages-{{ post.id if post else 'general' }}">
{% if recent_messages %}
{% for message in recent_messages %}
<div class="chat-embed-message">
<div class="message-header">
<strong>{{ message.user.nickname }}</strong>
{% if message.user.is_admin %}
<span class="badge bg-danger ms-1">ADMIN</span>
{% endif %}
<small class="text-muted ms-2">{{ message.created_at.strftime('%H:%M') }}</small>
</div>
<div class="message-content">{{ message.content }}</div>
</div>
{% endfor %}
{% if message_count > recent_messages|length %}
<div class="text-center mt-2">
<small class="text-muted">+ {{ message_count - recent_messages|length }} more messages</small>
</div>
{% endif %}
{% else %}
<div class="text-center text-muted py-3">
<i class="fas fa-comments fa-2x mb-2"></i>
<p>No messages yet. Start the conversation!</p>
</div>
{% endif %}
</div>
{% if current_user.is_authenticated %}
<div class="chat-embed-input">
<div class="input-group">
<input type="text"
class="form-control"
placeholder="Type your message..."
id="embedInput-{{ post.id if post else 'general' }}"
maxlength="500"
onkeypress="handleEmbedEnter(event, '{{ post.id if post else 'general' }}')">
<button class="btn btn-primary"
type="button"
onclick="sendEmbedMessage('{{ post.id if post else 'general' }}')">
<i class="fas fa-paper-plane"></i>
</button>
</div>
<small class="text-muted">Max 500 characters</small>
</div>
{% else %}
<div class="chat-embed-login text-center py-3">
<p class="text-muted mb-2">Join the discussion</p>
<a href="{{ url_for('auth.login') }}" class="btn btn-primary btn-sm me-2">Login</a>
<a href="{{ url_for('auth.register') }}" class="btn btn-outline-primary btn-sm">Register</a>
</div>
{% endif %}
<div class="chat-embed-actions text-center mt-2">
{% if room_id %}
<a href="{{ url_for('chat.room', room_id=room_id) }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-expand-alt me-1"></i>
Open Full Chat
</a>
{% endif %}
<a href="{{ url_for('chat.index') }}" class="btn btn-sm btn-outline-secondary ms-2">
<i class="fas fa-comments me-1"></i>
All Chats
</a>
</div>
</div>
</div>
<style>
.chat-embed-widget {
border: 1px solid #e0e0e0;
border-radius: 12px;
background: white;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
overflow: hidden;
transition: all 0.3s ease;
}
.chat-embed-widget:hover {
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.15);
}
.chat-embed-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem;
cursor: pointer;
transition: all 0.3s ease;
}
.chat-embed-header:hover {
background: linear-gradient(135deg, #5a6fd8 0%, #6a4190 100%);
}
.chat-embed-title {
font-weight: 600;
}
.chat-embed-toggle {
transition: transform 0.3s ease;
}
.chat-embed-widget.expanded .chat-embed-toggle {
transform: rotate(180deg);
}
.chat-embed-content {
border-top: 1px solid #e0e0e0;
}
.chat-embed-messages {
max-height: 300px;
overflow-y: auto;
padding: 1rem;
background: #f8f9fa;
}
.chat-embed-message {
background: white;
border-radius: 8px;
padding: 0.75rem;
margin-bottom: 0.5rem;
border-left: 3px solid #667eea;
}
.chat-embed-message:last-child {
margin-bottom: 0;
}
.chat-embed-message .message-header {
margin-bottom: 0.25rem;
}
.chat-embed-message .message-content {
color: #495057;
word-wrap: break-word;
}
.chat-embed-input {
padding: 1rem;
background: white;
border-top: 1px solid #e0e0e0;
}
.chat-embed-login {
padding: 1rem;
background: #f8f9fa;
border-top: 1px solid #e0e0e0;
}
.chat-embed-actions {
padding: 0.5rem 1rem 1rem;
background: white;
border-top: 1px solid #e0e0e0;
}
@media (max-width: 768px) {
.chat-embed-widget {
margin: 1rem -15px;
border-radius: 0;
}
.chat-embed-messages {
max-height: 200px;
}
}
</style>
<script>
function toggleChatEmbed(header) {
const widget = header.closest('.chat-embed-widget');
const content = widget.querySelector('.chat-embed-content');
const isExpanded = widget.classList.contains('expanded');
if (isExpanded) {
content.style.display = 'none';
widget.classList.remove('expanded');
} else {
content.style.display = 'block';
widget.classList.add('expanded');
// Load recent messages if not already loaded
const postId = widget.dataset.postId;
if (postId) {
loadEmbedMessages(postId);
}
}
}
function loadEmbedMessages(postId) {
const messagesContainer = document.getElementById(`embedMessages-${postId}`);
fetch(`/api/v1/chat/embed/messages?post_id=${postId}&limit=5`)
.then(response => response.json())
.then(data => {
if (data.success && data.messages.length > 0) {
let messagesHTML = '';
data.messages.forEach(message => {
messagesHTML += `
<div class="chat-embed-message">
<div class="message-header">
<strong>${message.user.nickname}</strong>
${message.user.is_admin ? '<span class="badge bg-danger ms-1">ADMIN</span>' : ''}
<small class="text-muted ms-2">${new Date(message.created_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}</small>
</div>
<div class="message-content">${message.content}</div>
</div>
`;
});
if (data.total_count > data.messages.length) {
messagesHTML += `
<div class="text-center mt-2">
<small class="text-muted">+ ${data.total_count - data.messages.length} more messages</small>
</div>
`;
}
messagesContainer.innerHTML = messagesHTML;
// Update message count
const countBadge = document.getElementById(`messageCount-${postId}`);
if (countBadge) {
countBadge.textContent = `${data.total_count} messages`;
}
}
})
.catch(error => {
console.error('Error loading embed messages:', error);
});
}
function handleEmbedEnter(event, postId) {
if (event.key === 'Enter') {
sendEmbedMessage(postId);
}
}
function sendEmbedMessage(postId) {
const input = document.getElementById(`embedInput-${postId}`);
const content = input.value.trim();
if (!content) return;
// Disable input while sending
input.disabled = true;
fetch('/api/v1/chat/embed/send', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
content: content,
post_id: postId || null
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
input.value = '';
// Reload messages to show the new one
loadEmbedMessages(postId);
} else {
alert('Error sending message: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to send message');
})
.finally(() => {
input.disabled = false;
input.focus();
});
}
// Auto-load messages when widget is first expanded
document.addEventListener('DOMContentLoaded', function() {
// Add click handlers to all chat embed widgets
document.querySelectorAll('.chat-embed-widget').forEach(widget => {
widget.addEventListener('click', function(e) {
if (e.target.closest('.chat-embed-header')) {
const postId = widget.dataset.postId;
if (postId && widget.classList.contains('expanded')) {
loadEmbedMessages(postId);
}
}
});
});
});
</script>

View File

@@ -0,0 +1,253 @@
{% extends "base.html" %}
{% block title %}Chat - Community Discussions{% endblock %}
{% block content %}
<div class="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-teal-900 pt-16">
<!-- Header Section -->
<div class="relative overflow-hidden py-16">
<div class="absolute inset-0 bg-black/20"></div>
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-8 border border-white/20">
<h1 class="text-4xl font-bold text-white mb-4">
<i class="fas fa-comments mr-3"></i>Community Chat
</h1>
<p class="text-blue-200 text-lg">Connect with fellow motorcycle adventurers</p>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-12 -mt-8">
<!-- Community Guidelines Card -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden mb-8">
<div class="bg-gradient-to-r from-green-600 to-emerald-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-shield-alt text-3xl mr-4"></i>
<div>
<h3 class="text-xl font-bold">Community Guidelines</h3>
<p class="text-green-100">Keep our community safe and welcoming</p>
</div>
</div>
</div>
<div class="p-6 bg-green-50">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 text-sm">
<div class="flex items-start">
<i class="fas fa-check-circle text-green-500 mr-2 mt-1"></i>
<span>Be respectful to all community members</span>
</div>
<div class="flex items-start">
<i class="fas fa-check-circle text-green-500 mr-2 mt-1"></i>
<span>Share motorcycle adventures and tips</span>
</div>
<div class="flex items-start">
<i class="fas fa-check-circle text-green-500 mr-2 mt-1"></i>
<span>Help others with technical questions</span>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8 max-w-4xl mx-auto">
<div class="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl hover:scale-105">
<div class="bg-gradient-to-r from-green-600 to-emerald-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-plus-circle text-3xl mr-4"></i>
<div>
<h4 class="text-xl font-bold">Create Chat Room</h4>
<p class="text-green-100">Start a new discussion</p>
</div>
</div>
</div>
<div class="p-6 text-center">
<p class="text-gray-600 mb-6">Start a new chat room on any motorcycle topic or connect it to a specific post</p>
<a href="{{ url_for('chat.create_room_form') }}"
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-green-600 to-emerald-600 text-white font-semibold rounded-lg hover:from-green-700 hover:to-emerald-700 transition-all duration-200">
<i class="fas fa-plus mr-2"></i>Create New Room
</a>
</div>
</div>
<div class="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl hover:scale-105">
<div class="bg-gradient-to-r from-blue-600 to-purple-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-newspaper text-3xl mr-4"></i>
<div>
<h4 class="text-xl font-bold">Post Discussions</h4>
<p class="text-blue-100">Chat about community posts</p>
</div>
</div>
</div>
<div class="p-6 text-center">
<p class="text-gray-600 mb-6">Join discussions about specific community posts and share your thoughts</p>
<a href="{{ url_for('chat.post_discussions') }}"
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-semibold rounded-lg hover:from-blue-700 hover:to-purple-700 transition-all duration-200">
<i class="fas fa-comments mr-2"></i>View Discussions
</a>
</div>
</div>
</div>
{% if user_rooms %}
<!-- Your Recent Chats -->
<div class="mb-8">
<h2 class="text-2xl font-bold text-white mb-6 flex items-center">
<i class="fas fa-history mr-3"></i>Your Recent Chats
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for room in user_rooms %}
<div class="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl hover:scale-105">
<div class="bg-gradient-to-r
{% if room.room_type == 'support' %}from-purple-600 to-indigo-600
{% elif room.room_type == 'public' %}from-blue-600 to-cyan-600
{% elif room.room_type == 'group' %}from-green-600 to-teal-600
{% else %}from-gray-600 to-slate-600{% endif %} p-6">
<div class="flex items-center justify-between text-white">
<div>
<h3 class="text-lg font-bold">{{ room.name }}</h3>
<p class="text-sm opacity-80">{{ room.room_type.replace('_', ' ').title() }}</p>
</div>
<i class="fas
{% if room.room_type == 'support' %}fa-headset
{% elif room.room_type == 'public' %}fa-users
{% elif room.room_type == 'group' %}fa-user-friends
{% else %}fa-comments{% endif %} text-2xl"></i>
</div>
</div>
<div class="p-6">
<p class="text-gray-600 mb-4">{{ room.description or 'No description available' }}</p>
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500">
<i class="fas fa-clock mr-1"></i>
{{ room.last_activity.strftime('%m/%d %H:%M') if room.last_activity else 'No activity' }}
</span>
<a href="{{ url_for('chat.room', room_id=room.id) }}"
class="inline-flex items-center px-4 py-2 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-semibold rounded-lg hover:from-blue-700 hover:to-purple-700 transition-all duration-200">
<i class="fas fa-sign-in-alt mr-2"></i>Join Chat
</a>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if public_rooms %}
<!-- Public Chat Rooms -->
<div class="mb-8">
<h2 class="text-2xl font-bold text-white mb-6 flex items-center">
<i class="fas fa-globe mr-3"></i>Public Chat Rooms
</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for room in public_rooms %}
<div class="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl hover:scale-105">
<div class="bg-gradient-to-r
{% if room.room_type == 'support' %}from-purple-600 to-indigo-600
{% elif room.room_type == 'public' %}from-blue-600 to-cyan-600
{% elif room.room_type == 'group' %}from-green-600 to-teal-600
{% else %}from-gray-600 to-slate-600{% endif %} p-6">
<div class="flex items-center justify-between text-white">
<div>
<h3 class="text-lg font-bold">{{ room.name }}</h3>
<p class="text-sm opacity-80">{{ room.room_type.replace('_', ' ').title() }}</p>
</div>
<div class="text-right">
<i class="fas
{% if room.room_type == 'support' %}fa-headset
{% elif room.room_type == 'public' %}fa-users
{% elif room.room_type == 'group' %}fa-user-friends
{% else %}fa-comments{% endif %} text-2xl"></i>
<p class="text-xs mt-1">{{ room.participants.count() }} members</p>
</div>
</div>
</div>
<div class="p-6">
<p class="text-gray-600 mb-3">{{ room.description or 'Join the conversation!' }}</p>
{% if room.related_post %}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-3 mb-4">
<p class="text-sm text-blue-700">
<i class="fas fa-link mr-1"></i>Related to: {{ room.related_post.title }}
</p>
</div>
{% endif %}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500">
<i class="fas fa-clock mr-1"></i>
{{ room.last_activity.strftime('%m/%d %H:%M') if room.last_activity else 'No activity' }}
</span>
<a href="{{ url_for('chat.room', room_id=room.id) }}"
class="inline-flex items-center px-4 py-2 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-semibold rounded-lg hover:from-blue-700 hover:to-purple-700 transition-all duration-200">
<i class="fas fa-sign-in-alt mr-2"></i>Join Chat
</a>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
{% if not user_rooms and not public_rooms %}
<!-- Empty State -->
<div class="text-center py-16">
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-12 border border-white/20">
<i class="fas fa-comments text-6xl text-white/50 mb-6"></i>
<h3 class="text-2xl font-bold text-white mb-4">No chat rooms available</h3>
<p class="text-blue-200 mb-8">Be the first to start a conversation!</p>
<a href="{{ url_for('chat.create_room_form') }}"
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-green-600 to-emerald-600 text-white font-semibold rounded-lg hover:from-green-700 hover:to-emerald-700 transition-all duration-200">
<i class="fas fa-plus mr-2"></i>Create First Room
</a>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Mobile app detection and API guidance
if (window.ReactNativeWebView || window.flutter_inappwebview) {
console.log('Mobile app detected - use API endpoints for better performance');
document.body.classList.add('mobile-app-view');
}
// Auto-refresh room list every 60 seconds (increased from 30s to reduce server load)
let refreshInterval;
function startAutoRefresh() {
refreshInterval = setInterval(() => {
if (document.visibilityState === 'visible' && !document.hidden) {
window.location.reload();
}
}, 60000);
}
// Pause refresh when page is hidden
document.addEventListener('visibilitychange', () => {
if (document.hidden) {
clearInterval(refreshInterval);
} else {
startAutoRefresh();
}
});
// Start auto-refresh on page load
startAutoRefresh();
// Smooth scrolling for better UX
document.querySelectorAll('a[href^="#"]').forEach(anchor => {
anchor.addEventListener('click', function (e) {
e.preventDefault();
const target = document.querySelector(this.getAttribute('href'));
if (target) {
target.scrollIntoView({
behavior: 'smooth',
block: 'start'
});
}
});
});
</script>
{% endblock %}

View File

@@ -0,0 +1,205 @@
{% extends "base.html" %}
{% block title %}Post Discussions - Chat Rooms{% endblock %}
{% block content %}
<div class="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-teal-900 pt-16">
<!-- Header Section -->
<div class="relative overflow-hidden py-12">
<div class="absolute inset-0 bg-black/20"></div>
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
<h1 class="text-3xl font-bold text-white mb-2">
<i class="fas fa-newspaper mr-3"></i>Post Discussions
</h1>
<p class="text-blue-200">Chat rooms linked to community posts</p>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-12 -mt-6">
<!-- Navigation -->
<div class="flex justify-center mb-8">
<div class="bg-white rounded-2xl shadow-xl p-2 flex space-x-2">
<a href="{{ url_for('chat.index') }}"
class="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-all duration-200">
<i class="fas fa-comments mr-2"></i>All Chats
</a>
<span class="px-4 py-2 bg-gradient-to-r from-blue-600 to-purple-600 text-white rounded-lg font-semibold">
<i class="fas fa-newspaper mr-2"></i>Post Discussions
</span>
<a href="{{ url_for('chat.create_room_form') }}"
class="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-all duration-200">
<i class="fas fa-plus mr-2"></i>Create Room
</a>
</div>
</div>
<!-- Statistics -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="bg-gradient-to-r from-blue-600 to-cyan-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-chart-bar text-3xl mr-4"></i>
<div>
<h3 class="text-xl font-bold">{{ total_discussions }}</h3>
<p class="text-blue-100">Total Post Discussions</p>
</div>
</div>
</div>
</div>
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="bg-gradient-to-r from-green-600 to-emerald-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-fire text-3xl mr-4"></i>
<div>
<h3 class="text-xl font-bold">{{ active_discussions }}</h3>
<p class="text-green-100">Active This Week</p>
</div>
</div>
</div>
</div>
</div>
{% if rooms.items %}
<!-- Discussion Rooms -->
<div class="space-y-6">
{% for room in rooms.items %}
<div class="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl">
<div class="p-6">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between">
<div class="flex-1">
<!-- Room Header -->
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<h3 class="text-xl font-bold text-gray-800 mb-2">{{ room.name }}</h3>
{% if room.description %}
<p class="text-gray-600 mb-3">{{ room.description }}</p>
{% endif %}
</div>
<span class="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm font-semibold ml-4">
<i class="fas fa-comments mr-1"></i>{{ room.message_count or 0 }} messages
</span>
</div>
<!-- Related Post -->
{% if room.related_post %}
<div class="bg-blue-50 border border-blue-200 rounded-xl p-4 mb-4">
<div class="flex items-start">
<i class="fas fa-link text-blue-600 mr-3 mt-1"></i>
<div class="flex-1">
<h4 class="font-semibold text-blue-800 mb-1">Discussing Post:</h4>
<a href="{{ url_for('community.post_detail', id=room.related_post.id) }}"
class="text-blue-700 hover:text-blue-900 font-medium">
{{ room.related_post.title }}
</a>
<p class="text-blue-600 text-sm mt-1">
by {{ room.related_post.author.nickname }} •
{{ room.related_post.created_at.strftime('%B %d, %Y') }}
</p>
</div>
</div>
</div>
{% endif %}
<!-- Room Info -->
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-500">
<span>
<i class="fas fa-user mr-1"></i>
Created by {{ room.created_by.nickname }}
</span>
<span>
<i class="fas fa-users mr-1"></i>
{{ room.participants.count() }} members
</span>
<span>
<i class="fas fa-clock mr-1"></i>
{% if room.last_activity %}
Last activity {{ room.last_activity.strftime('%m/%d/%Y at %I:%M %p') }}
{% else %}
No recent activity
{% endif %}
</span>
{% if room.is_private %}
<span class="bg-amber-100 text-amber-800 px-2 py-1 rounded-full">
<i class="fas fa-lock mr-1"></i>Private
</span>
{% endif %}
</div>
</div>
<!-- Action Buttons -->
<div class="mt-4 lg:mt-0 lg:ml-6 flex flex-col space-y-2">
<a href="{{ url_for('chat.room', room_id=room.id) }}"
class="inline-flex items-center justify-center px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-semibold rounded-lg hover:from-blue-700 hover:to-purple-700 transition-all duration-200">
<i class="fas fa-sign-in-alt mr-2"></i>Join Discussion
</a>
{% if room.related_post %}
<a href="{{ url_for('community.post_detail', id=room.related_post.id) }}"
class="inline-flex items-center justify-center px-6 py-2 bg-gray-100 text-gray-700 font-medium rounded-lg hover:bg-gray-200 transition-all duration-200">
<i class="fas fa-eye mr-2"></i>View Post
</a>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if rooms.pages > 1 %}
<div class="flex justify-center mt-8">
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="flex">
{% if rooms.has_prev %}
<a href="{{ url_for('chat.post_discussions', page=rooms.prev_num) }}"
class="px-4 py-2 text-gray-600 hover:bg-gray-100 transition-all duration-200">
<i class="fas fa-chevron-left"></i>
</a>
{% endif %}
{% for page_num in rooms.iter_pages() %}
{% if page_num %}
{% if page_num != rooms.page %}
<a href="{{ url_for('chat.post_discussions', page=page_num) }}"
class="px-4 py-2 text-gray-600 hover:bg-gray-100 transition-all duration-200">
{{ page_num }}
</a>
{% else %}
<span class="px-4 py-2 bg-blue-600 text-white">{{ page_num }}</span>
{% endif %}
{% else %}
<span class="px-4 py-2 text-gray-400">...</span>
{% endif %}
{% endfor %}
{% if rooms.has_next %}
<a href="{{ url_for('chat.post_discussions', page=rooms.next_num) }}"
class="px-4 py-2 text-gray-600 hover:bg-gray-100 transition-all duration-200">
<i class="fas fa-chevron-right"></i>
</a>
{% endif %}
</div>
</div>
</div>
{% endif %}
{% else %}
<!-- Empty State -->
<div class="text-center py-16">
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-12 border border-white/20">
<i class="fas fa-newspaper text-6xl text-white/50 mb-6"></i>
<h3 class="text-2xl font-bold text-white mb-4">No post discussions yet</h3>
<p class="text-blue-200 mb-8">Create the first chat room linked to a community post!</p>
<a href="{{ url_for('chat.create_room_form') }}"
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-green-600 to-emerald-600 text-white font-semibold rounded-lg hover:from-green-700 hover:to-emerald-700 transition-all duration-200">
<i class="fas fa-plus mr-2"></i>Create Post Discussion
</a>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,172 @@
{% extends "base.html" %}
{% block title %}{{ post.title }} - Discussions{% endblock %}
{% block content %}
<div class="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-teal-900 pt-16">
<!-- Header Section -->
<div class="relative overflow-hidden py-12">
<div class="absolute inset-0 bg-black/20"></div>
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
<div class="flex items-center justify-between">
<div class="flex-1">
<h1 class="text-2xl md:text-3xl font-bold text-white mb-2">
<i class="fas fa-comments mr-3"></i>Discussions for:
</h1>
<h2 class="text-xl md:text-2xl text-blue-200">{{ post.title }}</h2>
<p class="text-blue-300 text-sm mt-2">
by {{ post.author.nickname }} • {{ post.created_at.strftime('%B %d, %Y') }}
</p>
</div>
<a href="{{ url_for('community.post_detail', id=post.id) }}"
class="bg-white/20 hover:bg-white/30 px-4 py-2 rounded-lg transition-all duration-200 text-white">
<i class="fas fa-eye mr-2"></i>View Post
</a>
</div>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-12 -mt-6">
<!-- Navigation -->
<div class="flex justify-center mb-8">
<div class="bg-white rounded-2xl shadow-xl p-2 flex flex-wrap gap-2">
<a href="{{ url_for('chat.index') }}"
class="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-all duration-200">
<i class="fas fa-comments mr-2"></i>All Chats
</a>
<a href="{{ url_for('chat.post_discussions') }}"
class="px-4 py-2 text-gray-600 hover:text-gray-800 hover:bg-gray-100 rounded-lg transition-all duration-200">
<i class="fas fa-newspaper mr-2"></i>Post Discussions
</a>
<a href="{{ url_for('chat.create_room_form') }}?post_id={{ post.id }}"
class="px-4 py-2 bg-gradient-to-r from-green-600 to-emerald-600 text-white rounded-lg hover:from-green-700 hover:to-emerald-700 transition-all duration-200">
<i class="fas fa-plus mr-2"></i>New Discussion
</a>
</div>
</div>
<!-- Post Summary -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden mb-8">
<div class="bg-gradient-to-r from-blue-600 to-purple-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-file-alt text-2xl mr-3"></i>
<div>
<h3 class="text-xl font-bold">Original Post</h3>
<p class="text-blue-100 text-sm">{{ post.created_at.strftime('%B %d, %Y at %I:%M %p') }}</p>
</div>
</div>
</div>
<div class="p-6">
<div class="prose max-w-none">
{{ post.content[:300] }}{% if post.content|length > 300 %}...{% endif %}
</div>
<div class="flex items-center justify-between mt-4 pt-4 border-t border-gray-200">
<div class="flex items-center space-x-4 text-sm text-gray-500">
<span>
<i class="fas fa-user mr-1"></i>{{ post.author.nickname }}
</span>
{% if post.likes %}
<span>
<i class="fas fa-heart mr-1 text-red-500"></i>{{ post.likes.count() }} likes
</span>
{% endif %}
{% if post.comments %}
<span>
<i class="fas fa-comment mr-1 text-blue-500"></i>{{ post.comments.count() }} comments
</span>
{% endif %}
</div>
<a href="{{ url_for('community.post_detail', id=post.id) }}"
class="px-4 py-2 bg-blue-100 text-blue-700 rounded-lg hover:bg-blue-200 transition-all duration-200">
<i class="fas fa-external-link-alt mr-2"></i>Read Full Post
</a>
</div>
</div>
</div>
{% if rooms %}
<!-- Discussion Rooms -->
<div class="space-y-6">
<h2 class="text-2xl font-bold text-white mb-6 flex items-center">
<i class="fas fa-comments mr-3"></i>Discussion Rooms ({{ rooms|length }})
</h2>
{% for room in rooms %}
<div class="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl">
<div class="p-6">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between">
<div class="flex-1">
<div class="flex items-start justify-between mb-4">
<div class="flex-1">
<h3 class="text-xl font-bold text-gray-800 mb-2">{{ room.name }}</h3>
{% if room.description %}
<p class="text-gray-600 mb-3">{{ room.description }}</p>
{% endif %}
</div>
<div class="flex space-x-2 ml-4">
<span class="bg-blue-100 text-blue-800 px-3 py-1 rounded-full text-sm font-semibold">
<i class="fas fa-comments mr-1"></i>{{ room.message_count or 0 }}
</span>
{% if room.is_private %}
<span class="bg-amber-100 text-amber-800 px-3 py-1 rounded-full text-sm font-semibold">
<i class="fas fa-lock mr-1"></i>Private
</span>
{% endif %}
</div>
</div>
<div class="flex flex-wrap items-center gap-4 text-sm text-gray-500">
<span>
<i class="fas fa-user mr-1"></i>
Created by {{ room.created_by.nickname }}
</span>
<span>
<i class="fas fa-users mr-1"></i>
{{ room.participants.count() }} members
</span>
<span>
<i class="fas fa-clock mr-1"></i>
{% if room.last_activity %}
{{ room.last_activity.strftime('%m/%d/%Y at %I:%M %p') }}
{% else %}
No recent activity
{% endif %}
</span>
<span>
<i class="fas fa-calendar mr-1"></i>
Created {{ room.created_at.strftime('%m/%d/%Y') }}
</span>
</div>
</div>
<div class="mt-4 lg:mt-0 lg:ml-6">
<a href="{{ url_for('chat.room', room_id=room.id) }}"
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-semibold rounded-lg hover:from-blue-700 hover:to-purple-700 transition-all duration-200">
<i class="fas fa-sign-in-alt mr-2"></i>Join Discussion
</a>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<!-- Empty State -->
<div class="text-center py-16">
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-12 border border-white/20">
<i class="fas fa-comments text-6xl text-white/50 mb-6"></i>
<h3 class="text-2xl font-bold text-white mb-4">No discussions yet</h3>
<p class="text-blue-200 mb-8">Be the first to start a discussion about this post!</p>
<a href="{{ url_for('chat.create_room_form') }}?post_id={{ post.id }}"
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-green-600 to-emerald-600 text-white font-semibold rounded-lg hover:from-green-700 hover:to-emerald-700 transition-all duration-200">
<i class="fas fa-plus mr-2"></i>Start Discussion
</a>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,275 @@
{% extends "base.html" %}
{% block title %}{{ room.name }} - Chat{% endblock %}
{% block content %}
<!-- Chat Room Header -->
<div class="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-teal-900 py-8">
<div class="max-w-6xl mx-auto px-4">
<!-- Room Header Card -->
<div class="bg-white/10 backdrop-blur-md rounded-2xl p-6 mb-6 border border-white/20">
<div class="flex flex-col lg:flex-row lg:items-center lg:justify-between">
<div class="mb-4 lg:mb-0">
<div class="flex items-center mb-2">
<div class="w-12 h-12 bg-gradient-to-r from-blue-500 to-purple-500 rounded-xl flex items-center justify-center mr-4">
<i class="fas fa-comments text-white text-xl"></i>
</div>
<div>
<h1 class="text-2xl font-bold text-white">{{ room.name }}</h1>
{% if room.description %}
<p class="text-blue-200 mb-2">{{ room.description }}</p>
{% endif %}
</div>
</div>
<div class="flex flex-wrap gap-2">
{% if room.category %}
<span class="px-3 py-1 bg-blue-500/30 text-blue-200 rounded-full text-sm border border-blue-400/30">
<i class="fas fa-tag mr-1"></i>{{ room.category.title() }}
</span>
{% endif %}
{% if room.related_post %}
<span class="px-3 py-1 bg-green-500/30 text-green-200 rounded-full text-sm border border-green-400/30">
<i class="fas fa-link mr-1"></i>Linked to Post
</span>
{% endif %}
<span class="px-3 py-1 bg-purple-500/30 text-purple-200 rounded-full text-sm border border-purple-400/30">
<i class="fas fa-users mr-1"></i>{{ room.participants.count() if room.participants else 0 }} Members
</span>
</div>
{% if room.related_post %}
<div class="mt-3 p-3 bg-white/5 rounded-lg border border-white/10">
<div class="flex items-center text-sm text-gray-300">
<i class="fas fa-newspaper mr-2 text-green-400"></i>
<span class="mr-2">Discussing:</span>
<a href="{{ url_for('community.post_detail', post_id=room.related_post.id) }}"
class="text-green-300 hover:text-green-200 underline transition-colors"
target="_blank">
{{ room.related_post.title }}
</a>
</div>
</div>
{% endif %}
</div>
<div class="flex gap-2">
<button class="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg border border-white/20 transition-colors">
<i class="fas fa-users mr-1"></i> Members
</button>
<button class="px-4 py-2 bg-white/10 hover:bg-white/20 text-white rounded-lg border border-white/20 transition-colors">
<i class="fas fa-cog mr-1"></i> Settings
</button>
</div>
</div>
</div>
<!-- Chat Interface -->
<div class="bg-white/5 backdrop-blur-md rounded-2xl border border-white/20 overflow-hidden">
<!-- Messages Area -->
<div id="messages-container" class="h-96 overflow-y-auto p-6 space-y-4">
{% for message in messages %}
<div class="message mb-4 {{ 'ml-12' if message.sender_id == current_user.id else 'mr-12' }}">
<div class="flex {{ 'justify-end' if message.sender_id == current_user.id else 'justify-start' }}">
<div class="max-w-xs lg:max-w-md">
{% if not message.is_system_message %}
<div class="flex items-center mb-1 {{ 'justify-end' if message.sender_id == current_user.id else 'justify-start' }}">
<span class="text-xs text-gray-400">
{{ message.sender.nickname }}
{% if message.sender.is_admin %}
<span class="px-1 py-0.5 bg-yellow-100 text-yellow-800 text-xs rounded-full ml-1">ADMIN</span>
{% endif %}
• {{ message.created_at.strftime('%H:%M') }}
</span>
</div>
{% endif %}
<div class="rounded-2xl px-4 py-3 {{
'bg-gradient-to-r from-blue-600 to-purple-600 text-white' if message.sender_id == current_user.id else
'bg-yellow-100 border-yellow-300 text-yellow-800 text-center' if message.is_system_message else
'bg-white border border-gray-200 text-gray-800'
}}">
{% if message.is_system_message %}
<i class="fas fa-info-circle mr-2"></i>
{% endif %}
{{ message.content }}
{% if message.is_edited %}
<small class="opacity-75 text-xs block mt-1">(edited)</small>
{% endif %}
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Message Input -->
<div class="border-t border-white/20 p-4">
<form id="message-form" class="flex gap-3">
<div class="flex-1">
<input
type="text"
id="message-input"
class="w-full px-4 py-3 bg-white/10 border border-white/20 rounded-xl text-white placeholder-gray-300 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
placeholder="Type your message..."
maxlength="1000"
autocomplete="off"
>
</div>
<button
type="submit"
class="px-6 py-3 bg-gradient-to-r from-blue-600 to-purple-600 hover:from-blue-700 hover:to-purple-700 text-white rounded-xl font-medium transition-all duration-200 transform hover:scale-105 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2"
>
<i class="fas fa-paper-plane"></i>
</button>
</form>
</div>
</div>
</div>
</div>
<script>
const roomId = {{ room.id }};
const currentUserId = {{ current_user.id }};
let lastMessageId = {{ messages[-1].id if messages else 0 }};
// Message form handling
document.getElementById('message-form').addEventListener('submit', function(e) {
e.preventDefault();
sendMessage();
});
// Enter key handling
document.getElementById('message-input').addEventListener('keypress', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
function sendMessage() {
const input = document.getElementById('message-input');
const content = input.value.trim();
if (!content) return;
// Disable input while sending
input.disabled = true;
fetch(`/api/v1/chat/rooms/${roomId}/messages`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
content: content,
message_type: 'text'
})
})
.then(response => response.json())
.then(data => {
if (data.success) {
input.value = '';
addMessageToUI(data.message);
scrollToBottom();
} else {
alert('Error sending message: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to send message');
})
.finally(() => {
input.disabled = false;
input.focus();
});
}
function addMessageToUI(message) {
const messagesArea = document.getElementById('messages-container');
const messageDiv = document.createElement('div');
messageDiv.className = `message mb-4 ${message.user.id === currentUserId ? 'ml-12' : 'mr-12'}`;
messageDiv.setAttribute('data-message-id', message.id);
const isOwnMessage = message.user.id === currentUserId;
const isSystemMessage = message.message_type === 'system';
messageDiv.innerHTML = `
<div class="flex ${isOwnMessage ? 'justify-end' : 'justify-start'}">
<div class="max-w-xs lg:max-w-md">
${!isSystemMessage ? `
<div class="flex items-center mb-1 ${isOwnMessage ? 'justify-end' : 'justify-start'}">
<span class="text-xs text-gray-400">
${message.user.nickname} ${message.user.is_admin ? '<span class="px-1 py-0.5 bg-yellow-100 text-yellow-800 text-xs rounded-full ml-1">ADMIN</span>' : ''}${new Date(message.created_at).toLocaleTimeString([], {hour: '2-digit', minute:'2-digit'})}
</span>
</div>
` : ''}
<div class="rounded-2xl px-4 py-3 ${
isOwnMessage ? 'bg-gradient-to-r from-blue-600 to-purple-600 text-white' :
isSystemMessage ? 'bg-yellow-100 border-yellow-300 text-yellow-800 text-center' :
'bg-white border border-gray-200 text-gray-800'
}">
${isSystemMessage ? '<i class="fas fa-info-circle mr-2"></i>' : ''}
${message.content}
${message.is_edited ? '<small class="opacity-75 text-xs block mt-1"> (edited)</small>' : ''}
</div>
</div>
</div>
`;
messagesArea.appendChild(messageDiv);
lastMessageId = message.id;
}
function scrollToBottom() {
const messagesArea = document.getElementById('messages-container');
messagesArea.scrollTop = messagesArea.scrollHeight;
}
function loadNewMessages() {
fetch(`/api/v1/chat/rooms/${roomId}/messages?after=${lastMessageId}`)
.then(response => response.json())
.then(data => {
if (data.success && data.messages.length > 0) {
data.messages.forEach(message => {
addMessageToUI(message);
});
scrollToBottom();
}
})
.catch(error => {
console.error('Error loading new messages:', error);
});
}
// Auto-scroll to bottom on load
scrollToBottom();
// Poll for new messages every 3 seconds
setInterval(loadNewMessages, 3000);
// Auto-focus on message input
document.getElementById('message-input').focus();
// Mobile app integration
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'chat_room_opened',
roomId: roomId,
roomName: '{{ room.name }}'
}));
}
// Flutter WebView integration
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('chatRoomOpened', {
roomId: roomId,
roomName: '{{ room.name }}'
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,449 @@
{% extends "base.html" %}
{% block title %}Admin Support - Chat{% endblock %}
{% block head %}
<style>
.support-container {
min-height: calc(100vh - 80px);
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 2rem 0;
}
.support-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(10px);
border-radius: 20px;
padding: 2rem;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.2);
margin-bottom: 2rem;
}
.support-header {
text-align: center;
margin-bottom: 2rem;
}
.support-icon {
width: 80px;
height: 80px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 2rem;
margin: 0 auto 1rem;
}
.quick-actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1rem;
margin-bottom: 2rem;
}
.action-card {
background: white;
border-radius: 15px;
padding: 1.5rem;
text-align: center;
border: 2px solid #e9ecef;
transition: all 0.3s ease;
text-decoration: none;
color: inherit;
}
.action-card:hover {
border-color: #667eea;
transform: translateY(-5px);
box-shadow: 0 10px 25px rgba(102, 126, 234, 0.15);
text-decoration: none;
color: inherit;
}
.action-icon {
width: 50px;
height: 50px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.2rem;
margin: 0 auto 1rem;
}
.support-form {
background: white;
border-radius: 15px;
padding: 2rem;
border: 2px solid #e9ecef;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-control {
border: 2px solid #e9ecef;
border-radius: 10px;
padding: 0.75rem 1rem;
transition: all 0.3s ease;
}
.form-control:focus {
border-color: #667eea;
box-shadow: 0 0 0 0.2rem rgba(102, 126, 234, 0.25);
}
.priority-selector {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
}
.priority-option {
flex: 1;
padding: 0.75rem;
border: 2px solid #e9ecef;
border-radius: 10px;
text-align: center;
cursor: pointer;
transition: all 0.3s ease;
}
.priority-option.active {
border-color: #667eea;
background: #f8f9ff;
}
.priority-low { border-left: 4px solid #28a745; }
.priority-medium { border-left: 4px solid #ffc107; }
.priority-high { border-left: 4px solid #dc3545; }
.recent-tickets {
background: white;
border-radius: 15px;
padding: 1.5rem;
border: 2px solid #e9ecef;
}
.ticket-item {
display: flex;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #e9ecef;
transition: background 0.2s ease;
}
.ticket-item:hover {
background: #f8f9fa;
}
.ticket-item:last-child {
border-bottom: none;
}
.ticket-status {
width: 12px;
height: 12px;
border-radius: 50%;
margin-right: 1rem;
}
.status-open { background: #28a745; }
.status-pending { background: #ffc107; }
.status-closed { background: #6c757d; }
.ticket-info {
flex: 1;
}
.ticket-title {
font-weight: 600;
color: #495057;
margin-bottom: 0.25rem;
}
.ticket-meta {
font-size: 0.875rem;
color: #6c757d;
}
.btn-gradient {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border: none;
border-radius: 10px;
padding: 0.75rem 2rem;
color: white;
font-weight: 600;
transition: all 0.3s ease;
}
.btn-gradient:hover {
transform: translateY(-2px);
box-shadow: 0 5px 15px rgba(102, 126, 234, 0.3);
color: white;
}
@media (max-width: 768px) {
.support-container {
padding: 1rem;
}
.support-card {
padding: 1rem;
}
.quick-actions {
grid-template-columns: 1fr;
}
.priority-selector {
flex-direction: column;
}
}
</style>
{% endblock %}
{% block content %}
<div class="support-container">
<div class="container">
<div class="support-card">
<div class="support-header">
<div class="support-icon">
<i class="fas fa-headset"></i>
</div>
<h2>Admin Support</h2>
<p class="text-muted">Get help from our administrators for account issues, password resets, and technical support</p>
</div>
<div class="quick-actions">
<a href="#" class="action-card" onclick="startPasswordReset()">
<div class="action-icon">
<i class="fas fa-key"></i>
</div>
<h5>Password Reset</h5>
<p class="text-muted">Reset your account password with admin assistance</p>
</a>
<a href="#" class="action-card" onclick="startAccountIssue()">
<div class="action-icon">
<i class="fas fa-user-cog"></i>
</div>
<h5>Account Issues</h5>
<p class="text-muted">Login problems, profile updates, and account settings</p>
</a>
<a href="#" class="action-card" onclick="startTechnicalSupport()">
<div class="action-icon">
<i class="fas fa-tools"></i>
</div>
<h5>Technical Support</h5>
<p class="text-muted">App bugs, feature requests, and technical assistance</p>
</a>
<a href="#" class="action-card" onclick="startGeneralInquiry()">
<div class="action-icon">
<i class="fas fa-question-circle"></i>
</div>
<h5>General Inquiry</h5>
<p class="text-muted">Questions about features, policies, or general help</p>
</a>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="support-form">
<h4 class="mb-3">Create Support Ticket</h4>
<form id="supportForm">
<div class="form-group">
<label for="subject" class="form-label">Subject</label>
<input type="text" class="form-control" id="subject" name="subject" required
placeholder="Brief description of your issue">
</div>
<div class="form-group">
<label for="category" class="form-label">Category</label>
<select class="form-control" id="category" name="category" required>
<option value="">Select a category</option>
<option value="password_reset">Password Reset</option>
<option value="account_issues">Account Issues</option>
<option value="technical_support">Technical Support</option>
<option value="general_inquiry">General Inquiry</option>
<option value="bug_report">Bug Report</option>
<option value="feature_request">Feature Request</option>
</select>
</div>
<div class="form-group">
<label class="form-label">Priority</label>
<div class="priority-selector">
<div class="priority-option priority-low" data-priority="low">
<strong>Low</strong><br>
<small>General questions</small>
</div>
<div class="priority-option priority-medium active" data-priority="medium">
<strong>Medium</strong><br>
<small>Account issues</small>
</div>
<div class="priority-option priority-high" data-priority="high">
<strong>High</strong><br>
<small>Urgent problems</small>
</div>
</div>
<input type="hidden" id="priority" name="priority" value="medium">
</div>
<div class="form-group">
<label for="description" class="form-label">Description</label>
<textarea class="form-control" id="description" name="description" rows="6" required
placeholder="Please provide detailed information about your issue..."></textarea>
</div>
<div class="form-group">
<label for="contactMethod" class="form-label">Preferred Contact Method</label>
<select class="form-control" id="contactMethod" name="contact_method">
<option value="chat">Chat (Recommended)</option>
<option value="email">Email Notification</option>
</select>
</div>
<button type="submit" class="btn btn-gradient btn-lg">
<i class="fas fa-paper-plane me-2"></i>
Submit Support Request
</button>
</form>
</div>
</div>
<div class="col-lg-4">
<div class="recent-tickets">
<h5 class="mb-3">Your Recent Tickets</h5>
{% if recent_tickets %}
{% for ticket in recent_tickets %}
<div class="ticket-item">
<div class="ticket-status status-{{ ticket.status }}"></div>
<div class="ticket-info">
<div class="ticket-title">{{ ticket.subject }}</div>
<div class="ticket-meta">
{{ ticket.created_at.strftime('%b %d, %Y') }} •
{{ ticket.category.replace('_', ' ').title() }}
</div>
</div>
<a href="{{ url_for('chat.room', room_id=ticket.chat_room_id) }}" class="btn btn-sm btn-outline-primary">
View
</a>
</div>
{% endfor %}
{% else %}
<p class="text-muted text-center">No recent support tickets</p>
{% endif %}
</div>
<div class="recent-tickets mt-3">
<h6 class="mb-3">Support Information</h6>
<div class="alert alert-info">
<i class="fas fa-info-circle"></i>
<strong>Response Time:</strong> Most tickets are answered within 2-4 hours during business hours.
</div>
<div class="alert alert-warning">
<i class="fas fa-clock"></i>
<strong>Business Hours:</strong> Monday-Friday, 9 AM - 6 PM (Local Time)
</div>
</div>
</div>
</div>
</div>
</div>
<script>
// Priority selector handling
document.querySelectorAll('.priority-option').forEach(option => {
option.addEventListener('click', function() {
document.querySelectorAll('.priority-option').forEach(opt => opt.classList.remove('active'));
this.classList.add('active');
document.getElementById('priority').value = this.dataset.priority;
});
});
// Quick action handlers
function startPasswordReset() {
document.getElementById('subject').value = 'Password Reset Request';
document.getElementById('category').value = 'password_reset';
document.getElementById('description').value = 'I need help resetting my password. ';
document.getElementById('description').focus();
}
function startAccountIssue() {
document.getElementById('subject').value = 'Account Issue';
document.getElementById('category').value = 'account_issues';
document.getElementById('description').value = 'I am experiencing issues with my account: ';
document.getElementById('description').focus();
}
function startTechnicalSupport() {
document.getElementById('subject').value = 'Technical Support Request';
document.getElementById('category').value = 'technical_support';
document.getElementById('description').value = 'I need technical assistance with: ';
document.getElementById('description').focus();
}
function startGeneralInquiry() {
document.getElementById('subject').value = 'General Inquiry';
document.getElementById('category').value = 'general_inquiry';
document.getElementById('description').value = 'I have a question about: ';
document.getElementById('description').focus();
}
// Support form submission
document.getElementById('supportForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const submitButton = this.querySelector('button[type="submit"]');
const originalText = submitButton.innerHTML;
// Disable button and show loading
submitButton.disabled = true;
submitButton.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Creating Ticket...';
fetch('/api/v1/chat/support/create', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
alert('Support ticket created successfully! You will be redirected to the chat room.');
window.location.href = `/chat/room/${data.room_id}`;
} else {
alert('Error creating support ticket: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Failed to create support ticket. Please try again.');
})
.finally(() => {
submitButton.disabled = false;
submitButton.innerHTML = originalText;
});
});
// Mobile app integration
if (window.ReactNativeWebView) {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'support_page_opened'
}));
}
if (window.flutter_inappwebview) {
window.flutter_inappwebview.callHandler('supportPageOpened');
}
</script>
{% endblock %}

View File

@@ -1,201 +1,268 @@
{% extends "base.html" %}
{% block title %}Edit Adventure - {{ post.title }}{% endblock %}
{% block head %}
<style>
.content-section {
border: 2px dashed #d1d5db;
border-radius: 0.75rem;
padding: 1.5rem;
margin-bottom: 1rem;
transition: all 0.3s ease;
background: rgba(255, 255, 255, 0.05);
}
.content-section.editing {
border-color: #3b82f6;
background: rgba(59, 130, 246, 0.1);
}
.content-section.saved {
border-color: #10b981;
border-style: solid;
background: rgba(16, 185, 129, 0.1);
}
.cover-upload-area {
transition: all 0.3s ease;
cursor: pointer;
}
.cover-upload-area:hover {
background: rgba(255, 255, 255, 0.05);
}
/* Dropdown styling */
select option {
background-color: #1f2937;
color: #ffffff;
}
select option:checked {
background-color: #0891b2;
}
/* Section styling */
.section-actions-frame {
background: rgba(255, 255, 255, 0.05);
border: 1px solid rgba(255, 255, 255, 0.2);
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<!-- Form Section -->
<div class="col-lg-8">
<div class="card shadow-sm">
<div class="card-header bg-primary text-white">
<h4 class="mb-0">
<i class="fas fa-edit"></i> Edit Your Adventure
</h4>
</div>
<div class="card-body">
<form id="editPostForm" method="POST" enctype="multipart/form-data" action="{{ url_for('community.edit_post', id=post.id) }}">
<!-- Title -->
<div class="mb-4">
<label for="title" class="form-label fw-bold">
<i class="fas fa-heading text-primary"></i> Adventure Title *
</label>
<input type="text" class="form-control form-control-lg" id="title" name="title"
value="{{ post.title }}" required maxlength="100"
placeholder="Enter your adventure title">
<div class="form-text">Make it catchy and descriptive!</div>
</div>
<!-- Subtitle -->
<div class="mb-4">
<label for="subtitle" class="form-label fw-bold">
<i class="fas fa-text-height text-info"></i> Subtitle
</label>
<input type="text" class="form-control" id="subtitle" name="subtitle"
value="{{ post.subtitle or '' }}" maxlength="200"
placeholder="A brief description of your adventure">
<div class="form-text">Optional - appears under the main title</div>
</div>
<!-- Difficulty Level -->
<div class="mb-4">
<label for="difficulty" class="form-label fw-bold">
<i class="fas fa-mountain text-warning"></i> Difficulty Level *
</label>
<select class="form-select" id="difficulty" name="difficulty" required>
<option value="">Select difficulty...</option>
<option value="1" {% if post.difficulty == 1 %}selected{% endif %}>⭐ Easy - Beginner friendly</option>
<option value="2" {% if post.difficulty == 2 %}selected{% endif %}>⭐⭐ Moderate - Some experience needed</option>
<option value="3" {% if post.difficulty == 3 %}selected{% endif %}>⭐⭐⭐ Challenging - Good skills required</option>
<option value="4" {% if post.difficulty == 4 %}selected{% endif %}>⭐⭐⭐⭐ Hard - Advanced riders only</option>
<option value="5" {% if post.difficulty == 5 %}selected{% endif %}>⭐⭐⭐⭐⭐ Expert - Extreme difficulty</option>
</select>
</div>
<!-- Current Cover Image -->
{% if post.images %}
{% set cover_image = post.images | selectattr('is_cover', 'equalto', True) | first %}
{% if cover_image %}
<div class="mb-4">
<label class="form-label fw-bold">
<i class="fas fa-image text-success"></i> Current Cover Photo
</label>
<div class="current-cover-preview">
<img src="{{ cover_image.get_thumbnail_url() }}" alt="Current cover"
class="img-thumbnail" style="max-width: 200px;">
<small class="text-muted d-block mt-1">{{ cover_image.original_name }}</small>
</div>
</div>
{% endif %}
{% endif %}
<!-- Cover Photo Upload -->
<div class="mb-4">
<label for="cover_picture" class="form-label fw-bold">
<i class="fas fa-camera text-success"></i>
{% if post.images and post.images | selectattr('is_cover', 'equalto', True) | first %}
Replace Cover Photo
{% else %}
Cover Photo
{% endif %}
</label>
<input type="file" class="form-control" id="cover_picture" name="cover_picture"
accept="image/*" onchange="previewCoverImage(this)">
<div class="form-text">
Optional - Upload a new cover photo to replace the current one
</div>
<div id="cover_preview" class="mt-2"></div>
</div>
<!-- Adventure Story/Content -->
<div class="mb-4">
<label for="content" class="form-label fw-bold">
<i class="fas fa-book text-primary"></i> Your Adventure Story *
</label>
<textarea class="form-control" id="content" name="content" rows="8" required
placeholder="Tell us about your adventure... Where did you go? What did you see? Any challenges or highlights?">{{ post.content }}</textarea>
<div class="form-text">
<i class="fas fa-info-circle"></i>
You can use **bold text** and *italic text* in your story!
</div>
</div>
<!-- Current GPX File -->
{% if post.gpx_files %}
<div class="mb-4">
<label class="form-label fw-bold">
<i class="fas fa-route text-info"></i> Current GPS Track
</label>
{% for gpx_file in post.gpx_files %}
<div class="current-gpx-file border rounded p-3 bg-light">
<div class="d-flex align-items-center">
<i class="fas fa-file-alt text-info me-2"></i>
<div>
<strong>{{ gpx_file.original_name }}</strong>
<small class="text-muted d-block">
Size: {{ "%.1f"|format(gpx_file.size / 1024) }} KB
• Uploaded: {{ gpx_file.created_at.strftime('%Y-%m-%d') }}
</small>
</div>
<a href="{{ url_for('community.serve_gpx', post_folder=post.media_folder, filename=gpx_file.filename) }}"
class="btn btn-sm btn-outline-primary ms-auto">
<i class="fas fa-download"></i> Download
</a>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- GPX File Upload -->
<div class="mb-4">
<label for="gpx_file" class="form-label fw-bold">
<i class="fas fa-route text-info"></i>
{% if post.gpx_files %}
Replace GPS Track File
{% else %}
GPS Track File (GPX)
{% endif %}
</label>
<input type="file" class="form-control" id="gpx_file" name="gpx_file"
accept=".gpx" onchange="validateGpxFile(this)">
<div class="form-text">
Optional - Upload a new GPX file to replace the current route
</div>
<div id="gpx_info" class="mt-2"></div>
</div>
<!-- Submit Buttons -->
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a href="{{ url_for('community.profile') }}" class="btn btn-secondary me-md-2">
<i class="fas fa-arrow-left"></i> Cancel
</a>
<button type="button" class="btn btn-info me-md-2" onclick="previewPost()">
<i class="fas fa-eye"></i> Preview Changes
</button>
<button type="submit" class="btn btn-success">
<i class="fas fa-paper-plane"></i> Update & Resubmit for Review
</button>
</div>
</form>
</div>
</div>
<div class="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-teal-900 py-12">
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
<!-- Header -->
<div class="text-center mb-8">
<h1 class="text-4xl font-bold text-white mb-4">
<i class="fas fa-edit"></i> Edit Your Adventure
</h1>
<p class="text-blue-200 text-lg">
Update your motorcycle journey story - "{{ post.title }}"
</p>
</div>
<!-- Info Panel -->
<div class="col-lg-4">
<div class="card shadow-sm">
<div class="card-header bg-info text-white">
<h5 class="mb-0">
<i class="fas fa-info-circle"></i> Editing Guidelines
</h5>
<!-- Main Form -->
<form id="editPostForm" method="POST" action="{{ url_for('community.edit_post', id=post.id) }}" enctype="multipart/form-data" class="space-y-6">
<!-- Basic Information Section -->
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
<h2 class="text-2xl font-bold text-white mb-6">📝 Basic Information</h2>
<!-- Current Cover Image Display -->
{% if post.images %}
{% set cover_image = post.images | selectattr('is_cover', 'equalto', True) | first %}
{% if cover_image %}
<div class="mb-6">
<label class="block text-white font-semibold mb-2">
<i class="fas fa-image text-cyan-400"></i> Current Cover Photo
</label>
<div class="current-cover-preview bg-white/5 rounded-lg p-4 border border-white/20">
<img src="{{ cover_image.get_thumbnail_url() }}" alt="Current cover"
class="max-h-48 mx-auto rounded-lg border-2 border-white/20">
<p class="text-white/80 text-center mt-2 text-sm">{{ cover_image.original_name }}</p>
</div>
</div>
{% endif %}
{% endif %}
<!-- Cover Picture Upload -->
<div class="mb-6">
<label for="cover_picture" class="block text-white font-semibold mb-2">
{% if post.images and post.images | selectattr('is_cover', 'equalto', True) | first %}
Replace Cover Picture
{% else %}
Set Cover Picture for the Post
{% endif %}
</label>
<div class="cover-upload-area border-2 border-dashed border-white/30 rounded-lg p-6 text-center hover:border-white/50 transition-all duration-300">
<input type="file" id="cover_picture" name="cover_picture" accept="image/*" class="hidden">
<div class="cover-upload-content">
<div class="text-4xl mb-2">📸</div>
<p class="text-white/80 mb-2">Click to upload new cover image</p>
<p class="text-sm text-white/60">Recommended: 1920x1080 pixels</p>
</div>
<div class="cover-preview hidden">
<img class="cover-preview-image max-h-48 mx-auto rounded-lg" alt="Cover preview">
<button type="button" class="cover-remove-btn mt-2 px-3 py-1 bg-red-500/80 text-white rounded hover:bg-red-600 transition-colors">Remove</button>
</div>
</div>
</div>
<div class="card-body">
<div class="mb-3">
<h6><i class="fas fa-edit text-primary"></i> What happens after editing?</h6>
<p class="small">Your updated post will be resubmitted for admin review before being published again.</p>
</div>
<div class="mb-3">
<h6><i class="fas fa-image text-success"></i> Photo Guidelines</h6>
<ul class="small mb-0">
<li>Use high-quality images (JPEG, PNG)</li>
<li>Landscape orientation works best for cover photos</li>
<li>Maximum file size: 10MB</li>
<!-- Title -->
<div class="mb-6">
<label for="title" class="block text-white font-semibold mb-2">Adventure Title *</label>
<input type="text" id="title" name="title" required value="{{ post.title }}"
class="w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur-sm border border-white/20
text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-cyan-400
focus:border-transparent transition-all duration-300"
placeholder="Give your adventure a captivating title..." maxlength="100">
</div>
<!-- Subtitle -->
<div class="mb-6">
<label for="subtitle" class="block text-white font-semibold mb-2">Subtitle</label>
<input type="text" id="subtitle" name="subtitle" value="{{ post.subtitle or '' }}"
class="w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur-sm border border-white/20
text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-cyan-400
focus:border-transparent transition-all duration-300"
placeholder="A brief description of your adventure" maxlength="200">
</div>
<!-- Difficulty Rating -->
<div class="mb-6">
<label for="difficulty" class="block text-white font-semibold mb-2">Route Difficulty *</label>
<select id="difficulty" name="difficulty" required
class="w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur-sm border border-white/20
text-white focus:outline-none focus:ring-2 focus:ring-cyan-400
focus:border-transparent transition-all duration-300">
<option value="" class="bg-gray-800 text-gray-300">Select difficulty level...</option>
<option value="1" {% if post.difficulty == 1 %}selected{% endif %} class="bg-gray-800 text-green-400">🟢 Easy - Beginner friendly roads</option>
<option value="2" {% if post.difficulty == 2 %}selected{% endif %} class="bg-gray-800 text-yellow-400">🟡 Moderate - Some experience needed</option>
<option value="3" {% if post.difficulty == 3 %}selected{% endif %} class="bg-gray-800 text-orange-400">🟠 Challenging - Experienced riders</option>
<option value="4" {% if post.difficulty == 4 %}selected{% endif %} class="bg-gray-800 text-red-400">🔴 Difficult - Advanced skills required</option>
<option value="5" {% if post.difficulty == 5 %}selected{% endif %} class="bg-gray-800 text-purple-400">🟣 Expert - Only for experts</option>
</select>
</div>
</div>
<!-- Adventure Story Section -->
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
<h2 class="text-2xl font-bold text-white mb-6">📖 Adventure Story</h2>
<!-- Adventure Story/Content -->
<div class="mb-6">
<label for="content" class="block text-white font-semibold mb-2">
<i class="fas fa-book text-cyan-400"></i> Your Adventure Story *
</label>
<textarea id="content" name="content" rows="8" required
class="w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur-sm border border-white/20
text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-cyan-400
focus:border-transparent transition-all duration-300"
placeholder="Tell us about your adventure... Where did you go? What did you see? Any challenges or highlights?">{{ post.content }}</textarea>
<div class="text-blue-200 text-sm mt-2">
<i class="fas fa-info-circle"></i>
You can use **bold text** and *italic text* in your story!
</div>
</div>
</div>
<!-- Route File Section -->
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
<h2 class="text-2xl font-bold text-white mb-6">🗺️ Route File</h2>
<!-- Current GPX File Display -->
{% if post.gpx_files %}
<div class="mb-6">
<label class="block text-white font-semibold mb-2">
<i class="fas fa-route text-cyan-400"></i> Current GPS Track
</label>
{% for gpx_file in post.gpx_files %}
<div class="current-gpx-file bg-white/5 rounded-lg p-4 border border-white/20">
<div class="flex items-center justify-between">
<div class="flex items-center">
<i class="fas fa-file-alt text-cyan-400 mr-3 text-xl"></i>
<div>
<div class="text-white font-semibold">{{ gpx_file.original_name }}</div>
<div class="text-white/60 text-sm">
Size: {{ "%.1f"|format(gpx_file.size / 1024) }} KB
• Uploaded: {{ gpx_file.created_at.strftime('%Y-%m-%d') }}
</div>
</div>
</div>
<a href="{{ url_for('community.serve_gpx', post_folder=post.media_folder, filename=gpx_file.filename) }}"
class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg transition-colors">
<i class="fas fa-download"></i> Download
</a>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<!-- GPX File Upload -->
<div class="border-2 border-dashed border-white/30 rounded-lg p-8 text-center">
<i class="fas fa-route text-4xl text-blue-300 mb-4"></i>
<div class="text-white font-semibold mb-2">
{% if post.gpx_files %}
Replace GPX Route File
{% else %}
Upload GPX Route File
{% endif %}
</div>
<div class="text-blue-200 text-sm mb-4">
Share your exact route so others can follow your adventure
</div>
<input type="file" id="gpx_file" name="gpx_file" accept=".gpx" class="hidden">
<label for="gpx_file" class="inline-flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 cursor-pointer transition">
<i class="fas fa-upload mr-2"></i>
Choose GPX File
</label>
<div id="gpx_info" class="mt-4 text-green-300 hidden"></div>
</div>
</div>
<!-- Guidelines Section -->
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
<h2 class="text-2xl font-bold text-white mb-6">
<i class="fas fa-info-circle text-cyan-400"></i> Editing Guidelines
</h2>
<div class="grid md:grid-cols-2 gap-6">
<div>
<h3 class="text-lg font-semibold text-cyan-300 mb-3">
<i class="fas fa-edit"></i> What happens after editing?
</h3>
<p class="text-white/80 text-sm">Your updated post will be resubmitted for admin review before being published again.</p>
</div>
<div>
<h3 class="text-lg font-semibold text-cyan-300 mb-3">
<i class="fas fa-image"></i> Photo Guidelines
</h3>
<ul class="text-white/80 text-sm space-y-1">
<li>• Use high-quality images (JPEG, PNG)</li>
<li>• Landscape orientation works best</li>
<li>• Maximum file size: 10MB</li>
</ul>
</div>
<div class="mb-3">
<h6><i class="fas fa-route text-info"></i> GPX File Tips</h6>
<ul class="small mb-0">
<li>Export from your GPS device or app</li>
<li>Should contain track points</li>
<li>Will be displayed on the community map</li>
<div>
<h3 class="text-lg font-semibold text-cyan-300 mb-3">
<i class="fas fa-route"></i> GPX File Tips
</h3>
<ul class="text-white/80 text-sm space-y-1">
<li>• Export from your GPS device or app</li>
<li>• Should contain track points</li>
<li>• Will be displayed on the community map</li>
</ul>
</div>
<div class="mb-3">
<h6><i class="fas fa-star text-warning"></i> Difficulty Levels</h6>
<div class="small">
<div>
<h3 class="text-lg font-semibold text-cyan-300 mb-3">
<i class="fas fa-star"></i> Difficulty Levels
</h3>
<div class="text-white/80 text-sm space-y-1">
<div><strong>Easy:</strong> Paved roads, good weather</div>
<div><strong>Moderate:</strong> Some gravel, hills</div>
<div><strong>Challenging:</strong> Off-road, technical</div>
@@ -203,36 +270,54 @@
<div><strong>Expert:</strong> Dangerous, experts only</div>
</div>
</div>
</div>
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle"></i>
<div class="mt-6 p-4 bg-yellow-500/20 border border-yellow-400/50 rounded-lg">
<div class="flex items-center text-yellow-300">
<i class="fas fa-exclamation-triangle mr-2"></i>
<strong>Note:</strong> Updating your post will reset its status to "pending review."
</div>
</div>
</div>
</div>
<!-- Submit Buttons -->
<div class="text-center space-x-4">
<a href="{{ url_for('community.profile') }}" class="bg-gray-600 hover:bg-gray-700 text-white px-8 py-4 rounded-lg font-bold text-lg transition-all duration-200 shadow-lg inline-block">
<i class="fas fa-arrow-left mr-3"></i>
Cancel
</a>
<button type="button" onclick="previewPost()" class="bg-gradient-to-r from-blue-500 to-indigo-600 text-white px-8 py-4 rounded-lg font-bold text-lg hover:from-blue-600 hover:to-indigo-700 transform hover:scale-105 transition-all duration-200 shadow-lg">
<i class="fas fa-eye mr-3"></i>
Preview Changes
</button>
<button type="submit" class="bg-gradient-to-r from-orange-500 to-red-600 text-white px-12 py-4 rounded-lg font-bold text-lg hover:from-orange-600 hover:to-red-700 transform hover:scale-105 transition-all duration-200 shadow-lg">
<i class="fas fa-paper-plane mr-3"></i>
Update Adventure
</button>
</div>
</form>
</div>
</div>
<!-- Preview Modal -->
<div class="modal fade" id="previewModal" tabindex="-1" aria-labelledby="previewModalLabel" aria-hidden="true">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="previewModalLabel">
<i class="fas fa-eye"></i> Post Preview
<div class="modal-content bg-gray-900 text-white border-0">
<div class="modal-header border-gray-700">
<h5 class="modal-title text-white" id="previewModalLabel">
<i class="fas fa-eye text-cyan-400"></i> Adventure Preview
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="modal-body bg-gradient-to-br from-blue-900 via-purple-900 to-teal-900">
<div id="previewContent">
<!-- Preview content will be loaded here -->
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close Preview</button>
<button type="button" class="btn btn-success" onclick="submitForm()">
<i class="fas fa-paper-plane"></i> Looks Good - Update Post
<div class="modal-footer border-gray-700">
<button type="button" class="bg-gray-600 hover:bg-gray-700 text-white px-4 py-2 rounded-lg transition-colors" data-bs-dismiss="modal">Close Preview</button>
<button type="button" class="bg-gradient-to-r from-orange-500 to-red-600 text-white px-6 py-2 rounded-lg hover:from-orange-600 hover:to-red-700 transition-all duration-200" onclick="submitForm()">
<i class="fas fa-paper-plane mr-2"></i> Looks Good - Update Post
</button>
</div>
</div>
@@ -241,48 +326,72 @@
<!-- JavaScript -->
<script>
function previewCoverImage(input) {
const preview = document.getElementById('cover_preview');
preview.innerHTML = '';
if (input.files && input.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
preview.innerHTML = `
<div class="mt-2">
<img src="${e.target.result}" alt="Cover preview" class="img-thumbnail" style="max-width: 200px;">
<small class="text-success d-block mt-1">✓ New cover photo ready</small>
</div>
`;
};
reader.readAsDataURL(input.files[0]);
}
}
// Cover image preview functionality
document.addEventListener('DOMContentLoaded', function() {
const coverInput = document.getElementById('cover_picture');
const coverUploadArea = document.querySelector('.cover-upload-area');
const coverUploadContent = document.querySelector('.cover-upload-content');
const coverPreview = document.querySelector('.cover-preview');
const coverPreviewImage = document.querySelector('.cover-preview-image');
const coverRemoveBtn = document.querySelector('.cover-remove-btn');
function validateGpxFile(input) {
const info = document.getElementById('gpx_info');
info.innerHTML = '';
if (input.files && input.files[0]) {
const file = input.files[0];
if (file.name.toLowerCase().endsWith('.gpx')) {
info.innerHTML = `
<div class="alert alert-success">
<i class="fas fa-check-circle"></i>
GPX file selected: ${file.name} (${(file.size / 1024).toFixed(1)} KB)
</div>
`;
} else {
info.innerHTML = `
<div class="alert alert-danger">
<i class="fas fa-exclamation-triangle"></i>
Please select a valid GPX file
</div>
`;
input.value = '';
// Cover upload click handler
coverUploadArea.addEventListener('click', function() {
coverInput.click();
});
// Cover file change handler
coverInput.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
const reader = new FileReader();
reader.onload = function(e) {
coverPreviewImage.src = e.target.result;
coverUploadContent.classList.add('hidden');
coverPreview.classList.remove('hidden');
};
reader.readAsDataURL(file);
}
}
}
});
// Cover remove button
coverRemoveBtn.addEventListener('click', function(e) {
e.stopPropagation();
coverInput.value = '';
coverUploadContent.classList.remove('hidden');
coverPreview.classList.add('hidden');
});
// GPX file handler
const gpxInput = document.getElementById('gpx_file');
const gpxInfo = document.getElementById('gpx_info');
gpxInput.addEventListener('change', function(e) {
const file = e.target.files[0];
if (file) {
if (file.name.toLowerCase().endsWith('.gpx')) {
gpxInfo.innerHTML = `
<div class="text-green-300">
<i class="fas fa-check-circle mr-2"></i>
GPX file selected: ${file.name} (${(file.size / 1024).toFixed(1)} KB)
</div>
`;
gpxInfo.classList.remove('hidden');
} else {
gpxInfo.innerHTML = `
<div class="text-red-300">
<i class="fas fa-exclamation-triangle mr-2"></i>
Please select a valid GPX file
</div>
`;
gpxInfo.classList.remove('hidden');
gpxInput.value = '';
}
} else {
gpxInfo.classList.add('hidden');
}
});
});
function previewPost() {
// Get form data
@@ -291,16 +400,17 @@ function previewPost() {
const content = document.getElementById('content').value;
const difficulty = document.getElementById('difficulty').value;
// Get difficulty stars
const difficultyStars = '⭐'.repeat(difficulty);
const difficultyLabels = {
'1': 'Easy',
'2': 'Moderate',
'3': 'Challenging',
'4': 'Hard',
'5': 'Expert'
// Get difficulty display
const difficultyOptions = {
'1': { emoji: '🟢', text: 'Easy - Beginner friendly roads' },
'2': { emoji: '🟡', text: 'Moderate - Some experience needed' },
'3': { emoji: '🟠', text: 'Challenging - Experienced riders' },
'4': { emoji: '🔴', text: 'Difficult - Advanced skills required' },
'5': { emoji: '🟣', text: 'Expert - Only for experts' }
};
const difficultyDisplay = difficultyOptions[difficulty] || { emoji: '', text: 'Select difficulty' };
// Format content (simple markdown-like formatting)
const formattedContent = content
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
@@ -311,38 +421,38 @@ function previewPost() {
const previewHTML = `
<div class="post-preview">
<!-- Hero Section -->
<div class="hero-section bg-primary text-white p-4 rounded mb-4">
<div class="container">
<h1 class="display-4 mb-2">${title || 'Your Adventure Title'}</h1>
${subtitle ? `<p class="lead mb-3">${subtitle}</p>` : ''}
<div class="d-flex align-items-center">
<span class="badge bg-warning text-dark me-3">
${difficultyStars} ${difficultyLabels[difficulty] || 'Select difficulty'}
<div class="bg-gradient-to-r from-blue-600 to-purple-600 text-white p-8 rounded-2xl mb-6">
<div class="max-w-4xl mx-auto">
<h1 class="text-4xl font-bold mb-4">${title || 'Your Adventure Title'}</h1>
${subtitle ? `<p class="text-xl mb-4 text-blue-100">${subtitle}</p>` : ''}
<div class="flex items-center space-x-4">
<span class="inline-flex items-center px-3 py-1 bg-white/20 rounded-full text-sm font-medium">
${difficultyDisplay.emoji} ${difficultyDisplay.text}
</span>
<small>By {{ current_user.nickname }} • Updated today</small>
<span class="text-blue-200">Updated today</span>
</div>
</div>
</div>
<!-- Content Section -->
<div class="container">
<div class="row">
<div class="col-lg-8">
<div class="adventure-content">
<h3>Adventure Story</h3>
<div class="content-text">
${formattedContent || '<em>No content provided yet...</em>'}
<div class="max-w-4xl mx-auto">
<div class="grid lg:grid-cols-3 gap-8">
<div class="lg:col-span-2">
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
<h3 class="text-2xl font-bold text-white mb-4">Adventure Story</h3>
<div class="text-white/90 leading-relaxed">
${formattedContent || '<em class="text-white/60">No content provided yet...</em>'}
</div>
</div>
</div>
<div class="col-lg-4">
<div class="adventure-info">
<h5>Adventure Details</h5>
<ul class="list-unstyled">
<li><strong>Difficulty:</strong> ${difficultyStars} ${difficultyLabels[difficulty] || 'Not set'}</li>
<li><strong>Status:</strong> <span class="badge bg-warning">Pending Review</span></li>
<li><strong>Last Updated:</strong> Today</li>
</ul>
<div class="lg:col-span-1">
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
<h5 class="text-xl font-bold text-white mb-4">Adventure Details</h5>
<div class="space-y-3 text-white/80">
<div><strong>Difficulty:</strong> ${difficultyDisplay.emoji} ${difficultyDisplay.text}</div>
<div><strong>Status:</strong> <span class="inline-flex items-center px-2 py-1 bg-yellow-500/20 text-yellow-300 rounded-full text-sm">Pending Review</span></div>
<div><strong>Last Updated:</strong> Today</div>
</div>
</div>
</div>
</div>
@@ -360,7 +470,7 @@ function submitForm() {
document.getElementById('editPostForm').submit();
}
// Form submission with AJAX
// Form submission with enhanced feedback
document.getElementById('editPostForm').addEventListener('submit', function(e) {
e.preventDefault();
@@ -369,7 +479,7 @@ document.getElementById('editPostForm').addEventListener('submit', function(e) {
const originalText = submitButton.innerHTML;
// Show loading state
submitButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Updating...';
submitButton.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i> Updating...';
submitButton.disabled = true;
fetch(this.action, {
@@ -381,37 +491,50 @@ document.getElementById('editPostForm').addEventListener('submit', function(e) {
if (data.success) {
// Show success message
const alert = document.createElement('div');
alert.className = 'alert alert-success alert-dismissible fade show';
alert.className = 'fixed top-4 right-4 bg-green-500 text-white p-4 rounded-lg shadow-lg z-50';
alert.innerHTML = `
<i class="fas fa-check-circle"></i> ${data.message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="flex items-center">
<i class="fas fa-check-circle mr-2"></i>
${data.message}
</div>
`;
this.insertBefore(alert, this.firstChild);
document.body.appendChild(alert);
// Redirect after delay
// Remove alert and redirect after delay
setTimeout(() => {
alert.remove();
window.location.href = data.redirect_url;
}, 2000);
} else {
// Show error message
const alert = document.createElement('div');
alert.className = 'alert alert-danger alert-dismissible fade show';
alert.className = 'fixed top-4 right-4 bg-red-500 text-white p-4 rounded-lg shadow-lg z-50';
alert.innerHTML = `
<i class="fas fa-exclamation-triangle"></i> ${data.error}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="flex items-center">
<i class="fas fa-exclamation-triangle mr-2"></i>
${data.error}
<button onclick="this.parentElement.parentElement.remove()" class="ml-4 text-white hover:text-gray-300">
<i class="fas fa-times"></i>
</button>
</div>
`;
this.insertBefore(alert, this.firstChild);
document.body.appendChild(alert);
}
})
.catch(error => {
console.error('Error:', error);
const alert = document.createElement('div');
alert.className = 'alert alert-danger alert-dismissible fade show';
alert.className = 'fixed top-4 right-4 bg-red-500 text-white p-4 rounded-lg shadow-lg z-50';
alert.innerHTML = `
<i class="fas fa-exclamation-triangle"></i> An error occurred while updating your post.
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<div class="flex items-center">
<i class="fas fa-exclamation-triangle mr-2"></i>
An error occurred while updating your post.
<button onclick="this.parentElement.parentElement.remove()" class="ml-4 text-white hover:text-gray-300">
<i class="fas fa-times"></i>
</button>
</div>
`;
this.insertBefore(alert, this.firstChild);
document.body.appendChild(alert);
})
.finally(() => {
// Restore button state

View File

@@ -3,42 +3,116 @@
{% block title %}Motorcycle Adventures Romania{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
.map-container {
height: 500px;
border-radius: 1rem;
/* 3-row grid card layout */
.map-card {
display: flex;
flex-direction: column;
min-height: 800px;
background: rgba(255,255,255,0.07);
border-radius: 1.5rem;
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
border: 2px solid #10b981;
padding: 0;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.route-popup {
font-family: inherit;
.map-card-square {
width: 100%;
max-width: 700px;
aspect-ratio: 1 / 1;
margin: 0 auto;
background: rgba(255,255,255,0.07);
border-radius: 1.5rem;
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
border: 2px solid #10b981;
display: flex;
flex-direction: column;
overflow: hidden;
}
.route-popup .popup-title {
font-weight: 600;
color: #1f2937;
margin-bottom: 0.25rem;
.map-card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1.25rem 0.5rem 1.25rem;
}
.route-popup .popup-author {
color: #6b7280;
font-size: 0.875rem;
margin-bottom: 0.5rem;
.map-card-title {
font-size: 1.1rem;
font-weight: bold;
color: #fff;
}
.route-popup .popup-link {
background: linear-gradient(135deg, #f97316, #dc2626);
color: white;
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
text-decoration: none;
font-size: 0.875rem;
font-weight: 500;
display: inline-block;
transition: all 0.2s;
.map-card-count {
font-size: 0.95rem;
color: #a5b4fc;
text-align: right;
}
.route-popup .popup-link:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
.map-card-content {
flex: 1 1 0%;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
min-width: 0;
padding: 0;
}
.map-iframe-container {
width: 100%;
height: 100%;
aspect-ratio: 1 / 1;
background: #f0f0f0;
border-radius: 1rem;
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
border: 2px solid #10b981;
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
overflow: hidden;
}
.map-iframe {
width: 100%;
height: 100%;
aspect-ratio: 1 / 1;
border: none;
display: block;
border-radius: 1rem;
background: #f0f0f0;
}
/* Loading state for iframe */
.iframe-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
background: rgba(255, 255, 255, 0.95);
padding: 20px;
border-radius: 12px;
text-align: center;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255,255,255,0.2);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
min-width: 200px;
transition: opacity 0.3s ease;
}
.iframe-loading.hidden {
opacity: 0;
pointer-events: none;
}
.map-card-footer {
padding: 0.5rem 1.25rem 0.75rem 1.25rem;
background: transparent;
text-align: center;
}
@media (max-width: 900px) {
.map-iframe-container, .map-iframe {
height: 40vh;
min-height: 200px;
}
}
</style>
{% endblock %}
@@ -48,29 +122,44 @@
<!-- Header Section -->
<div class="relative overflow-hidden">
<div class="absolute inset-0 bg-black/20"></div>
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-12">
<div class="text-center mb-8">
<h1 class="text-4xl md:text-5xl font-bold text-white mb-4">
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-2 pb-2">
<div class="text-center mb-2">
<h1 class="text-2xl md:text-3xl font-bold text-white mb-1">
🏍️ Motorcycle Adventures Romania
</h1>
<p class="text-lg text-blue-100 max-w-3xl mx-auto">
<p class="text-base text-blue-100 max-w-2xl mx-auto">
Discover epic motorcycle routes, share your adventures, and connect with fellow riders across Romania's stunning landscapes.
</p>
</div>
</div>
</div>
<!-- Interactive Map Section -->
<!-- Interactive Map Section (Wide Rectangular Card) -->
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mb-12">
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
<div class="flex justify-between items-center mb-6">
<h2 class="text-2xl font-bold text-white">🗺️ Interactive Route Map</h2>
<div class="w-11/12 md:w-10/12 lg:w-5/6 xl:w-11/12 mx-auto bg-white/10 backdrop-blur-sm rounded-2xl p-4 border border-white/20 flex flex-col" style="">
<div class="flex justify-between items-center mb-2">
<h2 class="text-xl font-bold text-white">🗺️ Interactive Route Map</h2>
<div class="text-sm text-blue-200">
<span id="route-count">{{ posts_with_routes|length }}</span> routes discovered
</div>
</div>
<div id="romania-map" class="map-container"></div>
<p class="text-blue-200 text-sm mt-4 text-center">
<div class="flex-1 flex items-center justify-center">
<div class="w-full aspect-[10/7] rounded-xl overflow-hidden border-2 border-emerald-500 bg-gray-100 relative">
<!-- Loading indicator -->
<div id="iframe-loading" class="absolute inset-0 flex flex-col items-center justify-center bg-white/80 z-10">
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-orange-600 mb-2"></div>
<span class="text-gray-700 text-sm">Loading interactive map...</span>
</div>
<iframe
id="map-iframe"
src="{{ url_for('static', filename='map_iframe.html') }}"
class="w-full h-full border-0 rounded-xl bg-gray-100 aspect-[10/7]"
title="Adventure Routes Map"
onload="hideIframeLoading()">
</iframe>
</div>
</div>
<p class="text-blue-200 text-xs mt-2 text-center">
Click on any route to view the adventure story • Routes are updated live as new trips are shared
</p>
</div>
@@ -126,9 +215,16 @@
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
{% for post in posts.items %}
<article class="bg-white/10 backdrop-blur-sm rounded-xl overflow-hidden shadow-xl hover:shadow-2xl transition-all duration-300 border border-white/20 hover:border-white/40 transform hover:-translate-y-1">
{% if post.images %}
{% set cover_image = post.images.filter_by(is_cover=True).first() %}
{% if cover_image %}
<div class="aspect-w-16 aspect-h-9 bg-gray-900">
<img src="{{ url_for('static', filename='uploads/images/' + post.images[0].filename) }}"
<img src="{{ cover_image.get_url() }}"
alt="{{ post.title }}"
class="w-full h-48 object-cover">
</div>
{% elif post.images %}
<div class="aspect-w-16 aspect-h-9 bg-gray-900">
<img src="{{ post.images[0].get_url() }}"
alt="{{ post.title }}"
class="w-full h-48 object-cover">
</div>
@@ -261,68 +357,24 @@
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize map centered on Romania
var map = L.map('romania-map').setView([45.9432, 24.9668], 7);
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}).addTo(map);
// Custom route colors
var routeColors = ['#f97316', '#dc2626', '#059669', '#7c3aed', '#db2777', '#2563eb'];
var colorIndex = 0;
// Load and display routes
fetch('{{ url_for("community.api_routes") }}')
.then(response => response.json())
.then(routes => {
routes.forEach(route => {
if (route.coordinates && route.coordinates.length > 0) {
// Create polyline for the route
var routeLine = L.polyline(route.coordinates, {
color: routeColors[colorIndex % routeColors.length],
weight: 4,
opacity: 0.8
}).addTo(map);
// Create popup content
var popupContent = `
<div class="route-popup">
<div class="popup-title">${route.title}</div>
<div class="popup-author">by ${route.author}</div>
<a href="${route.url}" class="popup-link">View Adventure</a>
</div>
`;
// Add popup to route
routeLine.bindPopup(popupContent);
// Add click event to highlight route
routeLine.on('click', function(e) {
this.setStyle({
weight: 6,
opacity: 1
});
setTimeout(() => {
this.setStyle({
weight: 4,
opacity: 0.8
});
}, 2000);
});
colorIndex++;
}
});
// Update route count
document.getElementById('route-count').textContent = routes.length;
})
.catch(error => {
console.error('Error loading routes:', error);
});
});
// Function to hide iframe loading indicator
function hideIframeLoading() {
const loadingEl = document.getElementById('iframe-loading');
if (loadingEl) {
loadingEl.classList.add('hidden');
setTimeout(() => {
loadingEl.style.display = 'none';
}, 300);
}
}
// Fallback to hide loading after 5 seconds if iframe doesn't trigger onload
setTimeout(() => {
const loadingEl = document.getElementById('iframe-loading');
if (loadingEl && !loadingEl.classList.contains('hidden')) {
console.log('Iframe loading timeout, hiding loading indicator');
hideIframeLoading();
}
}, 5000);
</script>
{% endblock %}

View File

@@ -244,6 +244,8 @@
<script>
document.addEventListener('DOMContentLoaded', function() {
// Global storage for section files
window.sectionFiles = {};
let sectionCounter = 0;
// Populate form from URL parameters
@@ -418,18 +420,40 @@ document.addEventListener('DOMContentLoaded', function() {
imageInput.addEventListener('change', function() {
const files = Array.from(this.files);
// Store files in global storage for this section
if (!window.sectionFiles[sectionId]) {
window.sectionFiles[sectionId] = [];
}
files.forEach(file => {
if (file.type.startsWith('image/')) {
// Add to global storage
window.sectionFiles[sectionId].push(file);
const reader = new FileReader();
reader.onload = function(e) {
const imagePreview = document.createElement('div');
imagePreview.className = 'image-preview';
imagePreview.dataset.fileIndex = window.sectionFiles[sectionId].length - 1;
imagePreview.innerHTML = `
<img src="${e.target.result}" alt="Preview">
<div class="remove-btn">&times;</div>
`;
imagePreview.querySelector('.remove-btn').addEventListener('click', function() {
// Remove from global storage
const fileIndex = parseInt(imagePreview.dataset.fileIndex);
window.sectionFiles[sectionId].splice(fileIndex, 1);
// Update indices for remaining previews
const remainingPreviews = imagesPreview.querySelectorAll('.image-preview');
remainingPreviews.forEach((preview, index) => {
if (parseInt(preview.dataset.fileIndex) > fileIndex) {
preview.dataset.fileIndex = parseInt(preview.dataset.fileIndex) - 1;
}
});
imagePreview.remove();
});
@@ -609,6 +633,19 @@ document.addEventListener('DOMContentLoaded', function() {
if (gpxFile) {
formData.append('gpx_file', gpxFile);
}
// Add section images from all saved sections
let imageCounter = 0;
savedSections.forEach((section, sectionIndex) => {
const sectionId = section.dataset.sectionId;
if (window.sectionFiles[sectionId] && window.sectionFiles[sectionId].length > 0) {
window.sectionFiles[sectionId].forEach((file, fileIndex) => {
formData.append(`section_image_${imageCounter}`, file);
formData.append(`section_image_${imageCounter}_section`, sectionIndex);
imageCounter++;
});
}
});
// Show loading state
const submitBtn = document.querySelector('button[type="submit"]');

View File

@@ -3,12 +3,161 @@
{% block title %}{{ post.title }} - Moto Adventure{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""/>
<style>
.map-container {
height: 400px !important;
min-height: 400px;
width: 100%;
border-radius: 1rem;
overflow: hidden;
border: 2px solid #e5e7eb;
background-color: #f8f9fa;
position: relative;
}
/* Ensure Leaflet map fills container */
.leaflet-container {
height: 100% !important;
width: 100% !important;
background-color: #f8f9fa;
}
/* Interactive map specific styling */
#interactive-map {
height: 400px !important;
min-height: 400px;
width: 100%;
border-radius: 1rem;
overflow: hidden;
border: 2px solid #e5e7eb;
position: relative;
background-color: #f8f9fa;
}
/* Force map to be visible */
#interactive-map .leaflet-container {
height: 400px !important;
width: 100% !important;
}
/* Custom div icon styling */
.custom-div-icon {
background: transparent !important;
border: none !important;
}
/* Expanded map modal styling */
#expanded-map {
height: 100%;
min-height: 70vh;
border-radius: 0.5rem;
}
/* Interactive map container */
#interactive-map {
height: 400px;
border-radius: 1rem;
overflow: hidden;
border: 2px solid #e5e7eb;
position: relative;
}
/* Modal animations */
#expandedMapModal {
backdrop-filter: blur(4px);
}
#expandedMapModal.hidden {
opacity: 0;
pointer-events: none;
}
/* Loading indicator styling */
.map-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: rgba(255, 255, 255, 0.95);
padding: 1rem 1.5rem;
border-radius: 0.5rem;
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
z-index: 1000;
}
/* Custom leaflet control styling */
.leaflet-control-zoom {
border: none !important;
box-shadow: 0 2px 5px 0 rgba(0,0,0,0.16) !important;
}
.leaflet-control-zoom a {
border-radius: 4px !important;
border: 1px solid #ddd !important;
background-color: white !important;
color: #333 !important;
}
.leaflet-control-zoom a:hover {
background-color: #f5f5f5 !important;
}
/* Ensure map tiles load properly */
.leaflet-container {
background-color: #f8f9fa;
}
/* Leaflet popup custom styling */
.leaflet-popup-content-wrapper {
border-radius: 8px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
}
.leaflet-popup-content {
margin: 8px 12px;
line-height: 1.4;
}
/* Custom zoom control styling */
.leaflet-control-zoom {
border: none !important;
border-radius: 8px !important;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important;
}
.leaflet-control-zoom a {
border-radius: 4px !important;
color: #374151 !important;
font-weight: bold !important;
}
.leaflet-control-zoom a:hover {
background-color: #f3f4f6 !important;
color: #1f2937 !important;
}
/* Scale control styling */
.leaflet-control-scale {
background-color: rgba(255, 255, 255, 0.9) !important;
border-radius: 4px !important;
padding: 2px 6px !important;
font-size: 11px !important;
}
/* Map loading indicator */
.map-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1000;
background: rgba(255, 255, 255, 0.9);
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
</style>
{% endblock %}
@@ -85,62 +234,123 @@
<!-- Main Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Main Content -->
<div class="lg:col-span-2 space-y-8">
<!-- Adventure Story -->
<div class="lg:col-span-2">
<!-- Adventure Story Blog Post -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="bg-gradient-to-r from-blue-600 to-purple-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-book-open text-2xl mr-3"></i>
<div>
<h2 class="text-2xl font-bold">Adventure Story</h2>
<p class="text-blue-100">Discover the journey through the author's words</p>
<p class="text-blue-100">Follow the journey step by step</p>
</div>
</div>
</div>
<div class="p-6">
<div class="prose prose-lg max-w-none text-gray-700">
{{ post.content | safe | nl2br }}
</div>
</div>
</div>
<!-- Photo Gallery -->
{% if post.images.count() > 0 %}
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="bg-gradient-to-r from-green-600 to-teal-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-camera-retro text-2xl mr-3"></i>
<div>
<h2 class="text-2xl font-bold">Photo Gallery</h2>
<p class="text-green-100">Visual highlights from this adventure</p>
</div>
</div>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for image in post.images %}
<div class="relative rounded-lg overflow-hidden cursor-pointer group transition-transform duration-300 hover:scale-105"
onclick="openImageModal('{{ image.get_url() }}', '{{ image.description or image.original_name }}')">
<img src="{{ image.get_url() }}" alt="{{ image.description or image.original_name }}"
class="w-full h-64 object-cover">
{% if image.description %}
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-end">
<p class="text-white p-4 text-sm">{{ image.description }}</p>
</div>
<div class="p-8">
<div class="prose prose-lg max-w-none">
{% set content_sections = post.content.split('\n\n') %}
{% set section_images = post.images.filter_by(is_cover=False).all() %}
{% set images_per_section = (section_images|length / (content_sections|length))|round|int if content_sections|length > 0 else 0 %}
{% for section in content_sections %}
{% if section.strip() %}
<div class="mb-8 pb-6 {% if not loop.last %}border-b border-gray-200{% endif %}">
{% set section_text = section.strip() %}
<!-- Extract keywords and clean text -->
{% set keywords = [] %}
{% set clean_text = section_text %}
<!-- Process **keyword** patterns and standalone keywords -->
{% if '**' in section_text %}
{% set parts = section_text.split('**') %}
{% set clean_parts = [] %}
{% for i in range(parts|length) %}
{% if i % 2 == 1 %}
<!-- This is a keyword between ** ** -->
{% set _ = keywords.append(parts[i].strip()) %}
{% else %}
<!-- This is regular text -->
{% set _ = clean_parts.append(parts[i]) %}
{% endif %}
{% endfor %}
{% set clean_text = clean_parts|join(' ')|trim %}
{% endif %}
<!-- Also extract standalone keywords (before <br><br>) -->
{% if '<br>' in clean_text %}
{% set text_parts = clean_text.split('<br>') %}
{% set first_part = text_parts[0].strip() %}
<!-- If first part looks like keywords (short words), extract them -->
{% if first_part and first_part|length < 100 and ' ' in first_part %}
{% set potential_keywords = first_part.split() %}
{% if potential_keywords|length <= 5 %}
<!-- Likely keywords, add them and remove from text -->
{% for kw in potential_keywords %}
{% if kw.strip() %}
{% set _ = keywords.append(kw.strip()) %}
{% endif %}
{% endfor %}
<!-- Keep only text after the first <br><br> -->
{% set remaining_parts = text_parts[1:] %}
{% set clean_text = remaining_parts|join('<br>')|trim %}
{% endif %}
{% endif %}
{% endif %}
<!-- Clean up multiple <br> tags -->
{% set clean_text = clean_text.replace('<br><br><br>', '<br><br>').replace('<br> <br>', '<br><br>').strip() %}
<!-- Section Keywords -->
{% if keywords %}
<div class="mb-4">
{% for keyword in keywords %}
{% if keyword.strip() %}
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gradient-to-r from-amber-500 to-orange-500 text-white mr-2 mb-2">
<i class="fas fa-tag mr-1 text-xs"></i>
{{ keyword.strip() }}
</span>
{% endif %}
{% endfor %}
</div>
{% endif %}
<!-- Section Text -->
<div class="text-gray-700 leading-relaxed mb-6 text-lg">
{{ clean_text | safe | nl2br }}
</div>
<!-- Section Images -->
{% if section_images %}
{% set start_idx = loop.index0 * images_per_section %}
{% set end_idx = start_idx + images_per_section %}
{% set current_section_images = section_images[start_idx:end_idx] %}
{% if current_section_images %}
<div class="grid grid-cols-1 {% if current_section_images|length > 1 %}md:grid-cols-2{% endif %} gap-4 mb-6">
{% for image in current_section_images %}
<div class="relative rounded-xl overflow-hidden cursor-pointer group transition-all duration-300 hover:shadow-lg"
onclick="openImageModal('{{ image.get_url() }}', '{{ image.description or image.original_name }}')">
<img src="{{ image.get_url() }}" alt="{{ image.description or image.original_name }}"
class="w-full h-64 object-cover transition-transform duration-300 group-hover:scale-105">
<div class="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
{% if image.description %}
<div class="absolute bottom-0 left-0 right-0 p-3 text-white text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300">
{{ image.description }}
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% endif %}
{% endif %}
</div>
{% endif %}
{% if image.is_cover %}
<div class="absolute top-2 left-2">
<span class="inline-flex items-center px-2 py-1 rounded bg-yellow-500 text-white text-xs font-semibold">
<i class="fas fa-star mr-1"></i> Cover
</span>
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- Comments Section -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
@@ -209,6 +419,26 @@
<div class="space-y-8">
<!-- GPS Map and Route Information -->
{% if post.gpx_files.count() > 0 %}
<!-- Map Card (single route, styled like Adventure Info) -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden mb-8">
<div class="bg-gradient-to-r from-blue-600 to-purple-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-map-marked-alt text-2xl mr-3"></i>
<div>
<h2 class="text-xl font-bold">Map</h2>
<p class="text-blue-100">Full trip view</p>
</div>
</div>
</div>
<div class="p-0" style="height:400px;overflow:hidden;">
<iframe
id="route-map-iframe"
src="{{ url_for('static', filename='map_iframe_single.html') }}?route_id={{ post.gpx_files[0].id }}"
width="100%" height="400" style="border:0; border-radius:1rem; display:block; background:#f8f9fa;"
allowfullscreen loading="lazy" referrerpolicy="no-referrer-when-downgrade">
</iframe>
</div>
</div>
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="bg-gradient-to-r from-orange-600 to-red-600 p-6">
<div class="flex items-center text-white">
@@ -224,20 +454,29 @@
<!-- Route Statistics -->
<div class="grid grid-cols-2 gap-4 mb-6">
{% set gpx_file = post.gpx_files.first() %}
<div class="text-center p-4 bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg">
<div class="text-2xl font-bold text-blue-600" id="distance">-</div>
<div class="text-2xl font-bold text-blue-600" id="distance">
{{ gpx_file.total_distance if gpx_file and gpx_file.total_distance > 0 else '-' }}
</div>
<div class="text-sm text-gray-600">Distance (km)</div>
</div>
<div class="text-center p-4 bg-gradient-to-br from-green-50 to-green-100 rounded-lg">
<div class="text-2xl font-bold text-green-600" id="elevation-gain">-</div>
<div class="text-sm text-gray-600">Elevation (m)</div>
<div class="text-2xl font-bold text-green-600" id="elevation-gain">
{{ gpx_file.elevation_gain|int if gpx_file and gpx_file.elevation_gain > 0 else '-' }}
</div>
<div class="text-sm text-gray-600">Elevation Gain (m)</div>
</div>
<div class="text-center p-4 bg-gradient-to-br from-purple-50 to-purple-100 rounded-lg">
<div class="text-2xl font-bold text-purple-600" id="max-elevation">-</div>
<div class="text-2xl font-bold text-purple-600" id="max-elevation">
{{ gpx_file.max_elevation|int if gpx_file and gpx_file.max_elevation > 0 else '-' }}
</div>
<div class="text-sm text-gray-600">Max Elevation (m)</div>
</div>
<div class="text-center p-4 bg-gradient-to-br from-orange-50 to-orange-100 rounded-lg">
<div class="text-2xl font-bold text-orange-600" id="waypoints">-</div>
<div class="text-2xl font-bold text-orange-600" id="waypoints">
{{ gpx_file.total_points if gpx_file and gpx_file.total_points > 0 else '-' }}
</div>
<div class="text-sm text-gray-600">Track Points</div>
</div>
</div>
@@ -270,6 +509,9 @@
</div>
</div>
</div>
<!-- Interactive Map Card -->
<!-- Interactive Map Card removed as requested -->
{% endif %}
<!-- Adventure Info -->
@@ -309,6 +551,14 @@
<span class="text-gray-600">Photos</span>
<span class="text-gray-900">{{ post.images.count() }}</span>
</div>
<!-- Interactive Map Card (iframe, styled as in community index) -->
<div class="map-card-square mt-8 mb-8 mx-auto">
<div class="map-card-header">
<span class="map-card-title">🗺️ Interactive Route Map</span>
<span class="map-card-count">1 route</span>
</div>
</div>
{% endif %}
{% if post.gpx_files.count() > 0 %}
<div class="flex items-center justify-between">
@@ -319,6 +569,45 @@
</div>
</div>
</div>
<!-- Photo Gallery (Compact) -->
{% if post.images.count() > 0 %}
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="bg-gradient-to-r from-green-600 to-teal-600 p-4">
<div class="flex items-center text-white">
<i class="fas fa-camera-retro text-xl mr-2"></i>
<div>
<h2 class="text-lg font-bold">Photo Gallery</h2>
<p class="text-green-100 text-sm">{{ post.images.count() }} photos</p>
</div>
</div>
</div>
<div class="p-4">
<div class="grid grid-cols-2 gap-2">
{% for image in post.images %}
<div class="relative rounded-lg overflow-hidden cursor-pointer group"
onclick="openImageModal('{{ image.get_url() }}', '{{ image.description or image.original_name }}')">
<img src="{{ image.get_url() }}" alt="{{ image.description or image.original_name }}"
class="w-full h-20 object-cover transition-transform duration-300 group-hover:scale-110">
{% if image.is_cover %}
<div class="absolute top-1 left-1">
<span class="inline-flex items-center px-1 py-0.5 rounded text-xs font-semibold bg-yellow-500 text-white">
<i class="fas fa-star"></i>
</span>
</div>
{% endif %}
<div class="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
</div>
{% endfor %}
</div>
{% if post.images.count() > 4 %}
<div class="mt-3 text-center">
<span class="text-sm text-gray-500">Click any photo to view gallery</span>
</div>
{% endif %}
</div>
</div>
{% endif %}
</div>
</div>
</div>
@@ -326,136 +615,244 @@
<!-- Image Modal -->
<div id="imageModal" class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 hidden">
<div class="relative max-w-4xl max-h-full p-4">
<button onclick="closeImageModal()" class="absolute top-2 right-2 text-white text-2xl z-10 bg-black bg-opacity-50 w-10 h-10 rounded-full flex items-center justify-center hover:bg-opacity-75">
<i class="fas fa-times"></i>
</button>
<img id="modalImage" src="" alt="" class="max-w-full max-h-full rounded-lg">
<div id="modalCaption" class="text-white text-center mt-4 text-lg"></div>
<div class="max-w-4xl max-h-full p-4">
<div class="relative">
<button onclick="closeImageModal()"
class="absolute top-2 right-2 text-white bg-black bg-opacity-50 rounded-full w-8 h-8 flex items-center justify-center hover:bg-opacity-75 z-10">
<i class="fas fa-times"></i>
</button>
<img id="modalImage" src="" alt="" class="max-w-full max-h-screen object-contain rounded-lg">
<div id="modalCaption" class="text-white text-center mt-2 px-4"></div>
</div>
</div>
</div>
<!-- Expanded Map Modal -->
<div id="expandedMapModal" class="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50 hidden">
<div class="w-full h-full p-4 md:p-8 flex flex-col">
<div class="flex justify-end mb-4">
<button onclick="closeExpandedMap()"
class="text-white bg-black bg-opacity-50 rounded-full w-12 h-12 flex items-center justify-center hover:bg-opacity-75 transition-all duration-200">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<div class="flex-1 bg-white rounded-lg overflow-hidden shadow-2xl">
<div id="expanded-map" class="w-full h-full"></div>
</div>
</div>
</div>
<!-- Chat Discussion Widget -->
{% include 'chat/embed.html' %}
{% endblock %}
{% block scripts %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
crossorigin=""></script>
<script>
// Global variables
let map;
let interactiveMap;
let expandedMap;
let gpxPolyline;
let trackPointsData = [];
// Simple test to check if Leaflet is loaded
console.log('Leaflet loaded:', typeof L !== 'undefined');
// Initialize map if GPX files exist
{% if post.gpx_files.count() > 0 %}
document.addEventListener('DOMContentLoaded', function() {
// Initialize map
map = L.map('map').setView([45.9432, 24.9668], 10); // Romania center as default
console.log('DOM Content Loaded - Starting simple map test...');
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// Load and display GPX files
{% for gpx_file in post.gpx_files %}
loadGPXFile('{{ gpx_file.get_url() }}');
{% endfor %}
// Simple map initialization
setTimeout(function() {
console.log('Attempting to create basic map...');
try {
// Create a very basic map
const mapContainer = document.getElementById('interactive-map');
if (!mapContainer) {
console.error('Map container not found');
return;
}
console.log('Container found, creating map...');
// Clear any existing content
mapContainer.innerHTML = '';
// Create map with basic settings
interactiveMap = L.map('interactive-map').setView([45.9432, 24.9668], 6);
console.log('Map created, adding tiles...');
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(interactiveMap);
console.log('Tiles added, adding test marker...');
// Add a test marker
L.marker([45.9432, 24.9668])
.addTo(interactiveMap)
.bindPopup('🗺️ Interactive Map Test - Romania')
.openPopup();
console.log('✅ Basic map setup complete!');
// Hide loading indicator
const loadingDiv = document.getElementById('map-loading');
if (loadingDiv) {
loadingDiv.style.display = 'none';
}
// Hide fallback content
const fallbackDiv = document.getElementById('map-fallback');
if (fallbackDiv) {
fallbackDiv.style.display = 'none';
}
// Force resize
setTimeout(() => {
interactiveMap.invalidateSize();
console.log('Map size invalidated');
}, 500);
} catch (error) {
console.error('❌ Error creating map:', error);
// Show error in the container
const mapContainer = document.getElementById('interactive-map');
if (mapContainer) {
mapContainer.innerHTML = `
<div class="flex items-center justify-center h-full bg-red-50 text-red-600 p-4">
<div class="text-center">
<i class="fas fa-exclamation-triangle text-2xl mb-2"></i>
<div class="font-semibold">Map Loading Error</div>
<div class="text-sm">${error.message}</div>
</div>
</div>
`;
}
}
}, 1000);
});
function loadGPXFile(gpxUrl) {
fetch(gpxUrl)
.then(response => response.text())
.then(gpxContent => {
const parser = new DOMParser();
const gpxDoc = parser.parseFromString(gpxContent, 'application/xml');
// Global functions that need to be available regardless of GPX files
function expandMap() {
console.log('Expand map button clicked');
const modal = document.getElementById('expandedMapModal');
if (!modal) {
console.error('Expanded map modal not found');
return;
}
modal.classList.remove('hidden');
// Initialize expanded map after modal is shown
setTimeout(() => {
if (!expandedMap) {
console.log('Initializing expanded map...');
expandedMap = L.map('expanded-map', {
zoomControl: true,
scrollWheelZoom: true,
doubleClickZoom: true,
boxZoom: true,
dragging: true
}).setView([45.9432, 24.9668], 6);
// Parse track points
const trackPoints = [];
const trkpts = gpxDoc.getElementsByTagName('trkpt');
let totalDistance = 0;
let elevationGain = 0;
let maxElevation = 0;
let previousPoint = null;
// Add tile layer with scale control
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 18,
minZoom: 2
}).addTo(expandedMap);
for (let i = 0; i < trkpts.length; i++) {
const lat = parseFloat(trkpts[i].getAttribute('lat'));
const lon = parseFloat(trkpts[i].getAttribute('lon'));
const eleElement = trkpts[i].getElementsByTagName('ele')[0];
const elevation = eleElement ? parseFloat(eleElement.textContent) : 0;
trackPoints.push([lat, lon]);
if (elevation > maxElevation) {
maxElevation = elevation;
}
if (previousPoint) {
// Calculate distance
const distance = calculateDistance(previousPoint.lat, previousPoint.lon, lat, lon);
totalDistance += distance;
// Calculate elevation gain
if (elevation > previousPoint.elevation) {
elevationGain += (elevation - previousPoint.elevation);
}
}
previousPoint = { lat: lat, lon: lon, elevation: elevation };
// Add scale control
L.control.scale({
position: 'bottomleft',
metric: true,
imperial: false
}).addTo(expandedMap);
}
// Clear existing layers except tile layer
expandedMap.eachLayer(function(layer) {
if (layer instanceof L.Polyline || layer instanceof L.Marker) {
expandedMap.removeLayer(layer);
}
// Add track to map
if (trackPoints.length > 0) {
const polyline = L.polyline(trackPoints, {
color: '#e74c3c',
weight: 4,
opacity: 0.8
}).addTo(map);
// Fit map to track
map.fitBounds(polyline.getBounds(), { padding: [20, 20] });
// Add start and end markers
const startIcon = L.divIcon({
html: '<div class="w-6 h-6 bg-green-500 rounded-full border-2 border-white flex items-center justify-center text-white text-xs font-bold">S</div>',
className: 'custom-div-icon',
iconSize: [24, 24],
iconAnchor: [12, 12]
});
const endIcon = L.divIcon({
html: '<div class="w-6 h-6 bg-red-500 rounded-full border-2 border-white flex items-center justify-center text-white text-xs font-bold">E</div>',
className: 'custom-div-icon',
iconSize: [24, 24],
iconAnchor: [12, 12]
});
L.marker(trackPoints[0], { icon: startIcon })
.bindPopup('Start Point')
.addTo(map);
L.marker(trackPoints[trackPoints.length - 1], { icon: endIcon })
.bindPopup('End Point')
.addTo(map);
}
// Update statistics
document.getElementById('distance').textContent = totalDistance.toFixed(1);
document.getElementById('elevation-gain').textContent = Math.round(elevationGain);
document.getElementById('max-elevation').textContent = Math.round(maxElevation);
document.getElementById('waypoints').textContent = trackPoints.length;
})
.catch(error => {
console.error('Error loading GPX file:', error);
});
// Add the GPX track to expanded map if we have track data
if (trackPointsData && trackPointsData.length > 0) {
console.log('Adding track data to expanded map...');
const expandedPolyline = L.polyline(trackPointsData, {
color: '#ef4444',
weight: 5,
opacity: 0.9,
smoothFactor: 1
}).addTo(expandedMap);
// Fit map to track
expandedMap.fitBounds(expandedPolyline.getBounds(), {
padding: [30, 30],
maxZoom: 15
});
// Add enhanced markers
const startIcon = L.divIcon({
html: '<div class="w-8 h-8 bg-green-500 rounded-full border-2 border-white flex items-center justify-center text-white shadow-lg"><i class="fas fa-play"></i></div>',
className: 'custom-div-icon',
iconSize: [32, 32],
iconAnchor: [16, 16]
});
const endIcon = L.divIcon({
html: '<div class="w-8 h-8 bg-red-500 rounded-full border-2 border-white flex items-center justify-center text-white shadow-lg"><i class="fas fa-flag-checkered"></i></div>',
className: 'custom-div-icon',
iconSize: [32, 32],
iconAnchor: [16, 16]
});
L.marker(trackPointsData[0], { icon: startIcon })
.bindPopup('<strong>🚀 Start Point</strong>')
.addTo(expandedMap);
L.marker(trackPointsData[trackPointsData.length - 1], { icon: endIcon })
.bindPopup('<strong>🏁 End Point</strong>')
.addTo(expandedMap);
} else {
console.log('No track data available for expanded map');
// Show a message on the map
L.popup()
.setLatLng([45.9432, 24.9668])
.setContent('<div class="text-gray-600">📍 No GPX route data available</div>')
.openOn(expandedMap);
}
// Invalidate size to ensure proper rendering
expandedMap.invalidateSize();
console.log('✓ Expanded map setup complete');
}, 100);
}
function calculateDistance(lat1, lon1, lat2, lon2) {
const R = 6371; // Earth's radius in km
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
function closeExpandedMap() {
const modal = document.getElementById('expandedMapModal');
if (modal) {
modal.classList.add('hidden');
}
}
{% endif %}
// Close expanded map on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeExpandedMap();
}
});
// Image modal functionality
function openImageModal(imageSrc, imageTitle) {
@@ -488,7 +885,7 @@ function toggleLike(postId) {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
'X-CSRFToken': '{{ form.csrf_token.data }}'
}
})
.then(response => response.json())
@@ -505,7 +902,6 @@ function toggleLike(postId) {
likeBtn.classList.add('from-pink-600', 'to-red-600', 'hover:from-pink-700', 'hover:to-red-700');
likeText.textContent = 'Like this Adventure';
}
// Update like count in meta section
const likeCountSpan = document.querySelector('.bg-pink-500\\/20');
if (likeCountSpan) {

View File

@@ -1,941 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ post.title }} - Moto Adventure{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css">
<style>
body {
background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
min-height: 100vh;
}
.hero-section {
position: relative;
height: 70vh;
overflow: hidden;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
margin-bottom: -4rem;
}
.hero-image {
width: 100%;
height: 100%;
object-fit: cover;
position: absolute;
top: 0;
left: 0;
filter: brightness(0.7);
}
.hero-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0,0,0,0.9));
color: white;
padding: 3rem 0;
z-index: 2;
}
.hero-content {
position: relative;
z-index: 3;
}
.difficulty-badge {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.75rem 1.5rem;
border-radius: 50px;
font-weight: 700;
margin-bottom: 1.5rem;
backdrop-filter: blur(10px);
background: rgba(255,255,255,0.2);
border: 2px solid rgba(255,255,255,0.3);
color: white;
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
animation: glow 2s ease-in-out infinite alternate;
}
@keyframes glow {
from { box-shadow: 0 0 20px rgba(255,255,255,0.3); }
to { box-shadow: 0 0 30px rgba(255,255,255,0.5); }
}
.hero-title {
font-size: 3.5rem;
font-weight: 900;
text-shadow: 0 4px 8px rgba(0,0,0,0.5);
margin-bottom: 1rem;
line-height: 1.2;
}
.hero-subtitle {
font-size: 1.4rem;
opacity: 0.9;
text-shadow: 0 2px 4px rgba(0,0,0,0.5);
}
.content-wrapper {
position: relative;
z-index: 10;
padding-top: 4rem;
}
.post-meta {
background: rgba(255,255,255,0.95);
backdrop-filter: blur(20px);
border-radius: 20px;
padding: 2.5rem;
margin-bottom: 2rem;
box-shadow: 0 20px 60px rgba(0,0,0,0.1);
border: 1px solid rgba(255,255,255,0.2);
}
.post-content {
background: rgba(255,255,255,0.95);
backdrop-filter: blur(20px);
border-radius: 20px;
padding: 3rem;
margin: 2rem 0;
box-shadow: 0 20px 60px rgba(0,0,0,0.1);
border: 1px solid rgba(255,255,255,0.2);
line-height: 1.8;
font-size: 1.1rem;
}
.content-header {
display: flex;
align-items: center;
gap: 1rem;
margin-bottom: 2rem;
padding-bottom: 1rem;
border-bottom: 2px solid #e9ecef;
}
.content-icon {
width: 50px;
height: 50px;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
border-radius: 15px;
display: flex;
align-items: center;
justify-content: center;
color: white;
font-size: 1.5rem;
}
.media-gallery {
background: rgba(255,255,255,0.95);
backdrop-filter: blur(20px);
border-radius: 20px;
padding: 3rem;
margin: 2rem 0;
box-shadow: 0 20px 60px rgba(0,0,0,0.1);
border: 1px solid rgba(255,255,255,0.2);
}
.image-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(350px, 1fr));
gap: 1.5rem;
margin-top: 2rem;
}
.gallery-image {
position: relative;
border-radius: 15px;
overflow: hidden;
cursor: pointer;
transition: all 0.4s ease;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
}
.gallery-image:hover {
transform: translateY(-10px) scale(1.02);
box-shadow: 0 20px 50px rgba(0,0,0,0.3);
}
.gallery-image img {
width: 100%;
height: 280px;
object-fit: cover;
transition: transform 0.4s ease;
}
.gallery-image:hover img {
transform: scale(1.1);
}
.image-overlay {
position: absolute;
bottom: 0;
left: 0;
right: 0;
background: linear-gradient(transparent, rgba(0,0,0,0.8));
color: white;
padding: 1.5rem;
transform: translateY(100%);
transition: transform 0.4s ease;
}
.gallery-image:hover .image-overlay {
transform: translateY(0);
}
.map-container {
background: rgba(255,255,255,0.95);
backdrop-filter: blur(20px);
border-radius: 20px;
padding: 3rem;
margin: 2rem 0;
box-shadow: 0 20px 60px rgba(0,0,0,0.1);
border: 1px solid rgba(255,255,255,0.2);
}
#map {
height: 450px;
border-radius: 15px;
border: 3px solid #e9ecef;
box-shadow: 0 10px 30px rgba(0,0,0,0.2);
}
.gpx-info {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-radius: 15px;
padding: 2rem;
margin-top: 2rem;
border-left: 6px solid #007bff;
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
}
.stats-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: 1.5rem;
margin: 2rem 0;
}
.stat-card {
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
padding: 2rem;
border-radius: 15px;
text-align: center;
border: 2px solid #e9ecef;
transition: all 0.4s ease;
position: relative;
overflow: hidden;
}
.stat-card:hover {
border-color: #007bff;
transform: translateY(-8px);
box-shadow: 0 15px 40px rgba(0,123,255,0.2);
}
.stat-value {
font-size: 2.5rem;
font-weight: 900;
color: #007bff;
display: block;
text-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.stat-label {
color: #6c757d;
font-size: 1rem;
margin-top: 0.5rem;
font-weight: 600;
}
.author-info {
display: flex;
align-items: center;
gap: 1.5rem;
margin-bottom: 1.5rem;
padding: 1.5rem;
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-radius: 15px;
border-left: 6px solid #28a745;
}
.author-avatar {
width: 60px;
height: 60px;
border-radius: 50%;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
display: flex;
align-items: center;
justify-content: center;
color: white;
font-weight: bold;
font-size: 1.5rem;
box-shadow: 0 8px 25px rgba(0,0,0,0.2);
text-shadow: 0 2px 4px rgba(0,0,0,0.3);
}
.author-details h5 {
margin: 0;
color: #495057;
font-weight: 700;
}
.author-date {
color: #6c757d;
font-size: 0.95rem;
margin-top: 0.25rem;
}
.post-stats {
display: flex;
gap: 1rem;
margin-left: auto;
flex-wrap: wrap;
}
.stat-badge {
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
color: white;
padding: 0.75rem 1.25rem;
border-radius: 25px;
font-weight: 600;
box-shadow: 0 5px 15px rgba(0,123,255,0.3);
animation: float 3s ease-in-out infinite;
}
.stat-badge:nth-child(2) {
animation-delay: 0.5s;
background: linear-gradient(135deg, #28a745 0%, #1e7e34 100%);
box-shadow: 0 5px 15px rgba(40,167,69,0.3);
}
@keyframes float {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-5px); }
}
.action-buttons {
display: flex;
gap: 1.5rem;
margin-top: 2rem;
flex-wrap: wrap;
justify-content: center;
}
.btn-action {
border-radius: 50px;
padding: 1rem 2.5rem;
font-weight: 700;
text-decoration: none;
transition: all 0.4s ease;
display: inline-flex;
align-items: center;
gap: 0.75rem;
font-size: 1.1rem;
text-transform: uppercase;
letter-spacing: 0.5px;
position: relative;
overflow: hidden;
}
.btn-download {
background: linear-gradient(135deg, #28a745 0%, #20c997 100%);
color: white;
border: none;
box-shadow: 0 8px 25px rgba(40,167,69,0.3);
}
.btn-download:hover {
background: linear-gradient(135deg, #20c997 0%, #28a745 100%);
transform: translateY(-3px);
box-shadow: 0 12px 35px rgba(40,167,69,0.4);
color: white;
}
.btn-like {
background: linear-gradient(135deg, #dc3545 0%, #fd7e14 100%);
color: white;
border: none;
box-shadow: 0 8px 25px rgba(220,53,69,0.3);
}
.btn-like:hover {
background: linear-gradient(135deg, #fd7e14 0%, #dc3545 100%);
transform: translateY(-3px);
box-shadow: 0 12px 35px rgba(220,53,69,0.4);
color: white;
}
.btn-like.liked {
background: linear-gradient(135deg, #e74c3c 0%, #c0392b 100%);
animation: pulse 0.6s ease-in-out;
}
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
.status-badge {
position: absolute;
top: 2rem;
right: 2rem;
padding: 0.75rem 1.5rem;
border-radius: 50px;
font-weight: 700;
font-size: 1rem;
backdrop-filter: blur(10px);
border: 2px solid rgba(255,255,255,0.3);
z-index: 5;
}
.status-pending {
background: rgba(255,193,7,0.9);
color: #212529;
}
.status-published {
background: rgba(40,167,69,0.9);
color: white;
}
.comments-section {
background: rgba(255,255,255,0.95);
backdrop-filter: blur(20px);
border-radius: 20px;
padding: 3rem;
margin: 2rem 0;
box-shadow: 0 20px 60px rgba(0,0,0,0.1);
border: 1px solid rgba(255,255,255,0.2);
}
.comment {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
border-left: 6px solid #007bff;
padding: 1.5rem;
margin: 1.5rem 0;
border-radius: 0 15px 15px 0;
box-shadow: 0 5px 15px rgba(0,0,0,0.1);
transition: all 0.3s ease;
}
.comment:hover {
transform: translateX(10px);
box-shadow: 0 8px 25px rgba(0,0,0,0.15);
}
.comment-author {
font-weight: 700;
color: #007bff;
margin-bottom: 0.75rem;
font-size: 1.1rem;
}
.comment-date {
font-size: 0.9rem;
color: #6c757d;
float: right;
font-weight: 500;
}
.comment-content {
color: #495057;
line-height: 1.6;
font-size: 1rem;
}
.empty-state {
text-align: center;
padding: 3rem;
color: #6c757d;
}
.empty-state i {
font-size: 4rem;
margin-bottom: 1rem;
opacity: 0.5;
}
/* Mobile Responsiveness */
@media (max-width: 768px) {
.hero-section {
height: 50vh;
margin-bottom: -2rem;
}
.hero-title {
font-size: 2.5rem;
}
.hero-subtitle {
font-size: 1.2rem;
}
.content-wrapper {
padding-top: 2rem;
}
.post-meta, .post-content, .media-gallery, .map-container, .comments-section {
margin: 1rem;
padding: 1.5rem;
}
.image-grid {
grid-template-columns: 1fr;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
.action-buttons {
flex-direction: column;
align-items: center;
}
.author-info {
flex-direction: column;
text-align: center;
}
.post-stats {
margin-left: 0;
justify-content: center;
margin-top: 1rem;
}
}
</style>
{% endblock %}
{% block content %}
<div class="container-fluid p-0">
<!-- Hero Section -->
<div class="hero-section">
{% set cover_image = post.images.filter_by(is_cover=True).first() %}
{% if cover_image %}
<img src="{{ cover_image.get_url() }}" alt="{{ post.title }}" class="hero-image">
{% endif %}
<!-- Status Badge -->
{% if current_user.is_authenticated and (current_user.id == post.author_id or current_user.is_admin) %}
<div class="status-badge {{ 'status-published' if post.published else 'status-pending' }}">
{% if post.published %}
<i class="fas fa-check-circle"></i> Published
{% else %}
<i class="fas fa-clock"></i> Pending Review
{% endif %}
</div>
{% endif %}
<div class="hero-overlay">
<div class="container hero-content">
<div class="difficulty-badge">
{% for i in range(post.difficulty) %}
<i class="fas fa-star"></i>
{% endfor %}
{{ post.get_difficulty_label() }}
</div>
<h1 class="hero-title">{{ post.title }}</h1>
{% if post.subtitle %}
<p class="hero-subtitle">{{ post.subtitle }}</p>
{% endif %}
</div>
</div>
</div>
<div class="container content-wrapper">
<!-- Post Meta Information -->
<div class="post-meta">
<div class="author-info">
<div class="author-avatar">
{{ post.author.nickname[0].upper() }}
</div>
<div class="author-details">
<h5>{{ post.author.nickname }}</h5>
<div class="author-date">
<i class="fas fa-calendar-alt"></i>
Published on {{ post.created_at.strftime('%B %d, %Y') }}
</div>
</div>
<div class="post-stats">
<span class="stat-badge">
<i class="fas fa-heart"></i> {{ post.get_like_count() }} likes
</span>
<span class="stat-badge">
<i class="fas fa-comments"></i> {{ post.comments.count() }} comments
</span>
</div>
</div>
</div>
<!-- Post Content -->
<div class="post-content">
<div class="content-header">
<div class="content-icon">
<i class="fas fa-book-open"></i>
</div>
<div>
<h2 class="mb-0">Adventure Story</h2>
<p class="text-muted mb-0">Discover the journey through the author's words</p>
</div>
</div>
<div class="content-text">
{{ post.content | safe | nl2br }}
</div>
</div>
<!-- Media Gallery -->
{% if post.images.count() > 0 %}
<div class="media-gallery">
<div class="content-header">
<div class="content-icon">
<i class="fas fa-camera-retro"></i>
</div>
<div>
<h2 class="mb-0">Photo Gallery</h2>
<p class="text-muted mb-0">Visual highlights from this adventure</p>
</div>
</div>
<div class="image-grid">
{% for image in post.images %}
<div class="gallery-image" onclick="openImageModal('{{ image.get_url() }}', '{{ image.description or image.original_name }}')">
<img src="{{ image.get_url() }}" alt="{{ image.description or image.original_name }}" loading="lazy">
{% if image.description %}
<div class="image-overlay">
<p class="mb-0"><i class="fas fa-info-circle"></i> {{ image.description }}</p>
</div>
{% endif %}
{% if image.is_cover %}
<div class="position-absolute top-0 start-0 m-3">
<span class="badge bg-warning text-dark">
<i class="fas fa-star"></i> Cover
</span>
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- GPX Map and Route Information -->
{% if post.gpx_files.count() > 0 %}
<div class="map-container">
<div class="content-header">
<div class="content-icon">
<i class="fas fa-route"></i>
</div>
<div>
<h2 class="mb-0">Interactive Route Map</h2>
<p class="text-muted mb-0">Explore the GPS track and route statistics</p>
</div>
</div>
<div id="map"></div>
<div class="gpx-info">
<h4 class="mb-3">
<i class="fas fa-chart-line"></i> Route Statistics
</h4>
<div class="stats-grid">
<div class="stat-card">
<span class="stat-value" id="distance">-</span>
<div class="stat-label">
<i class="fas fa-road"></i> Distance (km)
</div>
</div>
<div class="stat-card">
<span class="stat-value" id="elevation-gain">-</span>
<div class="stat-label">
<i class="fas fa-mountain"></i> Elevation Gain (m)
</div>
</div>
<div class="stat-card">
<span class="stat-value" id="max-elevation">-</span>
<div class="stat-label">
<i class="fas fa-arrow-up"></i> Max Elevation (m)
</div>
</div>
<div class="stat-card">
<span class="stat-value" id="waypoints">-</span>
<div class="stat-label">
<i class="fas fa-map-pin"></i> Track Points
</div>
</div>
</div>
<div class="action-buttons">
{% for gpx_file in post.gpx_files %}
{% if current_user.is_authenticated %}
<a href="{{ gpx_file.get_url() }}" download="{{ gpx_file.original_name }}" class="btn-action btn-download">
<i class="fas fa-download"></i>
Download GPX ({{ "%.1f"|format(gpx_file.size / 1024) }} KB)
</a>
{% else %}
<a href="{{ url_for('auth.login') }}" class="btn-action btn-download">
<i class="fas fa-lock"></i>
Login to Download GPX
</a>
{% endif %}
{% endfor %}
{% if current_user.is_authenticated %}
<button class="btn-action btn-like" onclick="toggleLike({{ post.id }})">
<i class="fas fa-heart"></i>
<span id="like-text">Like this Adventure</span>
</button>
{% endif %}
</div>
</div>
</div>
{% endif %}
<!-- Comments Section -->
<div class="comments-section">
<div class="content-header">
<div class="content-icon">
<i class="fas fa-comment-dots"></i>
</div>
<div>
<h2 class="mb-0">Community Discussion</h2>
<p class="text-muted mb-0">Share your thoughts and experiences ({{ comments|length }})</p>
</div>
</div>
{% if current_user.is_authenticated %}
<div class="comment-form-wrapper">
<form method="POST" action="{{ url_for('community.add_comment', id=post.id) }}" class="mb-4">
{{ form.hidden_tag() }}
<div class="mb-3">
{{ form.content.label(class="form-label fw-bold") }}
{{ form.content(class="form-control", rows="4", placeholder="Share your thoughts about this adventure, ask questions, or provide helpful tips...") }}
</div>
<button type="submit" class="btn-action btn-download">
<i class="fas fa-paper-plane"></i> Post Comment
</button>
</form>
</div>
{% else %}
<div class="alert alert-info d-flex align-items-center">
<i class="fas fa-info-circle me-3 fs-4"></i>
<div>
<strong>Join the Discussion!</strong>
<p class="mb-0">
<a href="{{ url_for('auth.login') }}" class="alert-link">Login</a> or
<a href="{{ url_for('auth.register') }}" class="alert-link">create an account</a>
to leave a comment and join the adventure community.
</p>
</div>
</div>
{% endif %}
<div class="comments-list">
{% for comment in comments %}
<div class="comment">
<div class="comment-author">
<i class="fas fa-user-circle me-2"></i>
{{ comment.author.nickname }}
<span class="comment-date">
<i class="fas fa-clock"></i>
{{ comment.created_at.strftime('%B %d, %Y at %I:%M %p') }}
</span>
</div>
<div class="comment-content">{{ comment.content }}</div>
</div>
{% endfor %}
{% if comments|length == 0 %}
<div class="empty-state">
<i class="fas fa-comment-slash"></i>
<h5>No comments yet</h5>
<p>Be the first to share your thoughts about this adventure!</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Image Modal -->
<div class="modal fade" id="imageModal" tabindex="-1">
<div class="modal-dialog modal-xl">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="imageModalLabel">
<i class="fas fa-image me-2"></i>Image Gallery
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body text-center p-0">
<img id="modalImage" src="" alt="" class="img-fluid rounded">
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
let map;
let gpxLayer;
// Initialize map if GPX files exist
{% if post.gpx_files.count() > 0 %}
document.addEventListener('DOMContentLoaded', function() {
// Initialize map
map = L.map('map').setView([45.9432, 24.9668], 10); // Romania center as default
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// Load and display GPX files
{% for gpx_file in post.gpx_files %}
loadGPXFile('{{ gpx_file.get_url() }}');
{% endfor %}
});
function loadGPXFile(gpxUrl) {
fetch(gpxUrl)
.then(response => response.text())
.then(gpxContent => {
const parser = new DOMParser();
const gpxDoc = parser.parseFromString(gpxContent, 'application/xml');
// Parse track points
const trackPoints = [];
const trkpts = gpxDoc.getElementsByTagName('trkpt');
let totalDistance = 0;
let elevationGain = 0;
let maxElevation = 0;
let previousPoint = null;
for (let i = 0; i < trkpts.length; i++) {
const lat = parseFloat(trkpts[i].getAttribute('lat'));
const lon = parseFloat(trkpts[i].getAttribute('lon'));
const eleElement = trkpts[i].getElementsByTagName('ele')[0];
const elevation = eleElement ? parseFloat(eleElement.textContent) : 0;
trackPoints.push([lat, lon]);
if (elevation > maxElevation) {
maxElevation = elevation;
}
if (previousPoint) {
// Calculate distance
const distance = calculateDistance(previousPoint.lat, previousPoint.lon, lat, lon);
totalDistance += distance;
// Calculate elevation gain
if (elevation > previousPoint.elevation) {
elevationGain += (elevation - previousPoint.elevation);
}
}
previousPoint = { lat: lat, lon: lon, elevation: elevation };
}
// Add track to map
if (trackPoints.length > 0) {
gpxLayer = L.polyline(trackPoints, {
color: '#e74c3c',
weight: 4,
opacity: 0.8
}).addTo(map);
// Fit map to track
map.fitBounds(gpxLayer.getBounds(), { padding: [20, 20] });
// Add start and end markers
const startIcon = L.divIcon({
html: '<i class="fas fa-play" style="color: green; font-size: 20px;"></i>',
iconSize: [30, 30],
className: 'custom-div-icon'
});
const endIcon = L.divIcon({
html: '<i class="fas fa-flag-checkered" style="color: red; font-size: 20px;"></i>',
iconSize: [30, 30],
className: 'custom-div-icon'
});
L.marker(trackPoints[0], { icon: startIcon })
.bindPopup('Start Point')
.addTo(map);
L.marker(trackPoints[trackPoints.length - 1], { icon: endIcon })
.bindPopup('End Point')
.addTo(map);
}
// Update statistics
document.getElementById('distance').textContent = totalDistance.toFixed(1);
document.getElementById('elevation-gain').textContent = Math.round(elevationGain);
document.getElementById('max-elevation').textContent = Math.round(maxElevation);
document.getElementById('waypoints').textContent = trackPoints.length;
})
.catch(error => {
console.error('Error loading GPX file:', error);
});
}
function calculateDistance(lat1, lon1, lat2, lon2) {
const R = 6371; // Earth's radius in km
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
{% endif %}
// Image modal functionality
function openImageModal(imageSrc, imageTitle) {
document.getElementById('modalImage').src = imageSrc;
document.getElementById('imageModalLabel').textContent = imageTitle;
new bootstrap.Modal(document.getElementById('imageModal')).show();
}
// Like functionality
function toggleLike(postId) {
fetch(`/community/post/${postId}/like`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
const likeBtn = document.querySelector('.btn-like');
const likeText = document.getElementById('like-text');
if (data.liked) {
likeBtn.classList.add('liked');
likeText.textContent = 'Liked!';
} else {
likeBtn.classList.remove('liked');
likeText.textContent = 'Like this Adventure';
}
// Update like count
const likeCountBadge = document.querySelector('.stat-badge');
likeCountBadge.innerHTML = `<i class="fas fa-heart"></i> ${data.count} likes`;
})
.catch(error => {
console.error('Error:', error);
});
}
</script>
{% endblock %}

View File

@@ -1,521 +0,0 @@
{% extends "base.html" %}
{% block title %}{{ post.title }} - Moto Adventure{% endblock %}
{% block head %}
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<style>
.map-container {
height: 400px;
border-radius: 1rem;
overflow: hidden;
}
</style>
{% endblock %}
{% block content %}
<div class="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-teal-900 pt-16">
<!-- Hero Section -->
<div class="relative overflow-hidden py-16">
{% set cover_image = post.images.filter_by(is_cover=True).first() %}
{% if cover_image %}
<div class="absolute inset-0">
<img src="{{ cover_image.get_url() }}" alt="{{ post.title }}"
class="w-full h-full object-cover opacity-30">
</div>
{% endif %}
<!-- Status Badge -->
{% if current_user.is_authenticated and (current_user.id == post.author_id or current_user.is_admin) %}
<div class="absolute top-4 right-4 z-10">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium
{{ 'bg-green-100 text-green-800' if post.published else 'bg-yellow-100 text-yellow-800' }}">
{% if post.published %}
<i class="fas fa-check-circle mr-1"></i> Published
{% else %}
<i class="fas fa-clock mr-1"></i> Pending Review
{% endif %}
</span>
</div>
{% endif %}
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
<!-- Difficulty Badge -->
<div class="inline-flex items-center px-4 py-2 rounded-full bg-white/10 backdrop-blur-sm border border-white/20 text-white mb-6">
{% for i in range(post.difficulty) %}
<i class="fas fa-star text-yellow-400 mr-1"></i>
{% endfor %}
<span class="ml-2 font-semibold">{{ post.get_difficulty_label() }}</span>
</div>
<h1 class="text-4xl md:text-6xl font-bold text-white mb-4">{{ post.title }}</h1>
{% if post.subtitle %}
<p class="text-xl text-blue-100 max-w-3xl mx-auto">{{ post.subtitle }}</p>
{% endif %}
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-12">
<!-- Post Meta Information -->
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20 mb-8 -mt-8 relative z-10">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-4">
<div class="flex items-center space-x-4">
<div class="w-12 h-12 bg-gradient-to-r from-orange-500 to-red-600 rounded-full flex items-center justify-center text-white font-bold text-lg">
{{ post.author.nickname[0].upper() }}
</div>
<div>
<h3 class="text-white font-semibold text-lg">{{ post.author.nickname }}</h3>
<p class="text-blue-200 text-sm">
<i class="fas fa-calendar-alt mr-1"></i>
Published on {{ post.created_at.strftime('%B %d, %Y') }}
</p>
</div>
</div>
<div class="flex flex-wrap gap-3">
<span class="inline-flex items-center px-3 py-1 rounded-full bg-pink-500/20 text-pink-200 text-sm">
<i class="fas fa-heart mr-1"></i> {{ post.get_like_count() }} likes
</span>
<span class="inline-flex items-center px-3 py-1 rounded-full bg-blue-500/20 text-blue-200 text-sm">
<i class="fas fa-comments mr-1"></i> {{ post.comments.count() }} comments
</span>
</div>
</div>
</div>
<!-- Main Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-8">
<!-- Main Content -->
<div class="lg:col-span-2 space-y-8">
<!-- Adventure Story -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="bg-gradient-to-r from-blue-600 to-purple-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-book-open text-2xl mr-3"></i>
<div>
<h2 class="text-2xl font-bold">Adventure Story</h2>
<p class="text-blue-100">Discover the journey through the author's words</p>
</div>
</div>
</div>
<div class="p-6">
<div class="prose prose-lg max-w-none text-gray-700">
{{ post.content | safe | nl2br }}
</div>
</div>
</div>
<!-- Photo Gallery -->
{% if post.images.count() > 0 %}
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="bg-gradient-to-r from-green-600 to-teal-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-camera-retro text-2xl mr-3"></i>
<div>
<h2 class="text-2xl font-bold">Photo Gallery</h2>
<p class="text-green-100">Visual highlights from this adventure</p>
</div>
</div>
</div>
<div class="p-6">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
{% for image in post.images %}
<div class="relative rounded-lg overflow-hidden cursor-pointer group transition-transform duration-300 hover:scale-105"
onclick="openImageModal('{{ image.get_url() }}', '{{ image.description or image.original_name }}')">
<img src="{{ image.get_url() }}" alt="{{ image.description or image.original_name }}"
class="w-full h-64 object-cover">
{% if image.description %}
<div class="absolute inset-0 bg-black/50 opacity-0 group-hover:opacity-100 transition-opacity duration-300 flex items-end">
<p class="text-white p-4 text-sm">{{ image.description }}</p>
</div>
{% endif %}
{% if image.is_cover %}
<div class="absolute top-2 left-2">
<span class="inline-flex items-center px-2 py-1 rounded bg-yellow-500 text-white text-xs font-semibold">
<i class="fas fa-star mr-1"></i> Cover
</span>
</div>
{% endif %}
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- Comments Section -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="bg-gradient-to-r from-purple-600 to-pink-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-comment-dots text-2xl mr-3"></i>
<div>
<h2 class="text-2xl font-bold">Community Discussion</h2>
<p class="text-purple-100">Share your thoughts and experiences ({{ comments|length }})</p>
</div>
</div>
</div>
<div class="p-6">
{% if current_user.is_authenticated %}
<form method="POST" action="{{ url_for('community.add_comment', id=post.id) }}" class="mb-6">
{{ form.hidden_tag() }}
<div class="mb-4">
{{ form.content.label(class="block text-sm font-medium text-gray-700 mb-2") }}
{{ form.content(class="w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-blue-500 focus:border-transparent", rows="4", placeholder="Share your thoughts about this adventure...") }}
</div>
<button type="submit" class="inline-flex items-center px-4 py-2 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-semibold rounded-lg hover:from-blue-700 hover:to-purple-700 transition-all duration-200">
<i class="fas fa-paper-plane mr-2"></i> Post Comment
</button>
</form>
{% else %}
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4 mb-6">
<div class="flex items-center">
<i class="fas fa-info-circle text-blue-600 mr-3"></i>
<div>
<p class="text-blue-800">
<a href="{{ url_for('auth.login') }}" class="font-semibold hover:underline">Login</a> or
<a href="{{ url_for('auth.register') }}" class="font-semibold hover:underline">create an account</a>
to join the discussion.
</p>
</div>
</div>
</div>
{% endif %}
<div class="space-y-4">
{% for comment in comments %}
<div class="bg-gray-50 rounded-lg p-4 border-l-4 border-blue-500">
<div class="flex items-center justify-between mb-2">
<h4 class="font-semibold text-gray-900">{{ comment.author.nickname }}</h4>
<span class="text-sm text-gray-500">
{{ comment.created_at.strftime('%B %d, %Y at %I:%M %p') }}
</span>
</div>
<p class="text-gray-700">{{ comment.content }}</p>
</div>
{% endfor %}
{% if comments|length == 0 %}
<div class="text-center py-8 text-gray-500">
<i class="fas fa-comment-slash text-4xl mb-4 opacity-50"></i>
<h5 class="text-lg font-semibold mb-2">No comments yet</h5>
<p>Be the first to share your thoughts about this adventure!</p>
</div>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-8">
<!-- GPS Map and Route Information -->
{% if post.gpx_files.count() > 0 %}
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="bg-gradient-to-r from-orange-600 to-red-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-route text-2xl mr-3"></i>
<div>
<h2 class="text-xl font-bold">Route Map</h2>
<p class="text-orange-100">GPS track and statistics</p>
</div>
</div>
</div>
<div class="p-6">
<div id="map" class="map-container mb-6 shadow-lg"></div>
<!-- Route Statistics -->
<div class="grid grid-cols-2 gap-4 mb-6">
<div class="text-center p-4 bg-gradient-to-br from-blue-50 to-blue-100 rounded-lg">
<div class="text-2xl font-bold text-blue-600" id="distance">-</div>
<div class="text-sm text-gray-600">Distance (km)</div>
</div>
<div class="text-center p-4 bg-gradient-to-br from-green-50 to-green-100 rounded-lg">
<div class="text-2xl font-bold text-green-600" id="elevation-gain">-</div>
<div class="text-sm text-gray-600">Elevation (m)</div>
</div>
<div class="text-center p-4 bg-gradient-to-br from-purple-50 to-purple-100 rounded-lg">
<div class="text-2xl font-bold text-purple-600" id="max-elevation">-</div>
<div class="text-sm text-gray-600">Max Elevation (m)</div>
</div>
<div class="text-center p-4 bg-gradient-to-br from-orange-50 to-orange-100 rounded-lg">
<div class="text-2xl font-bold text-orange-600" id="waypoints">-</div>
<div class="text-sm text-gray-600">Track Points</div>
</div>
</div>
<!-- Action Buttons -->
<div class="space-y-3">
{% for gpx_file in post.gpx_files %}
{% if current_user.is_authenticated %}
<a href="{{ gpx_file.get_url() }}" download="{{ gpx_file.original_name }}"
class="block w-full text-center px-4 py-2 bg-gradient-to-r from-green-600 to-emerald-600 text-white font-semibold rounded-lg hover:from-green-700 hover:to-emerald-700 transition-all duration-200 transform hover:scale-105">
<i class="fas fa-download mr-2"></i>
Download GPX ({{ "%.1f"|format(gpx_file.size / 1024) }} KB)
</a>
{% else %}
<a href="{{ url_for('auth.login') }}"
class="block w-full text-center px-4 py-2 bg-gray-400 text-white font-semibold rounded-lg hover:bg-gray-500 transition-all duration-200">
<i class="fas fa-lock mr-2"></i>
Login to Download GPX
</a>
{% endif %}
{% endfor %}
{% if current_user.is_authenticated %}
<button onclick="toggleLike({{ post.id }})"
class="w-full px-4 py-2 bg-gradient-to-r from-pink-600 to-red-600 text-white font-semibold rounded-lg hover:from-pink-700 hover:to-red-700 transition-all duration-200 transform hover:scale-105">
<i class="fas fa-heart mr-2"></i>
<span id="like-text">Like this Adventure</span>
</button>
{% endif %}
</div>
</div>
</div>
{% endif %}
<!-- Adventure Info -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="bg-gradient-to-r from-indigo-600 to-blue-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-info-circle text-2xl mr-3"></i>
<div>
<h2 class="text-xl font-bold">Adventure Info</h2>
<p class="text-indigo-100">Trip details</p>
</div>
</div>
</div>
<div class="p-6">
<div class="space-y-4">
<div class="flex items-center justify-between">
<span class="text-gray-600">Difficulty</span>
<div class="flex items-center">
{% for i in range(post.difficulty) %}
<i class="fas fa-star text-yellow-500"></i>
{% endfor %}
{% for i in range(5 - post.difficulty) %}
<i class="far fa-star text-gray-300"></i>
{% endfor %}
</div>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600">Published</span>
<span class="text-gray-900">{{ post.created_at.strftime('%B %d, %Y') }}</span>
</div>
<div class="flex items-center justify-between">
<span class="text-gray-600">Author</span>
<span class="text-gray-900">{{ post.author.nickname }}</span>
</div>
{% if post.images.count() > 0 %}
<div class="flex items-center justify-between">
<span class="text-gray-600">Photos</span>
<span class="text-gray-900">{{ post.images.count() }}</span>
</div>
{% endif %}
{% if post.gpx_files.count() > 0 %}
<div class="flex items-center justify-between">
<span class="text-gray-600">GPS Files</span>
<span class="text-gray-900">{{ post.gpx_files.count() }}</span>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Image Modal -->
<div id="imageModal" class="fixed inset-0 bg-black bg-opacity-75 flex items-center justify-center z-50 hidden">
<div class="relative max-w-4xl max-h-full p-4">
<button onclick="closeImageModal()" class="absolute top-2 right-2 text-white text-2xl z-10 bg-black bg-opacity-50 w-10 h-10 rounded-full flex items-center justify-center hover:bg-opacity-75">
<i class="fas fa-times"></i>
</button>
<img id="modalImage" src="" alt="" class="max-w-full max-h-full rounded-lg">
<div id="modalCaption" class="text-white text-center mt-4 text-lg"></div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script>
let map;
// Initialize map if GPX files exist
{% if post.gpx_files.count() > 0 %}
document.addEventListener('DOMContentLoaded', function() {
// Initialize map
map = L.map('map').setView([45.9432, 24.9668], 10); // Romania center as default
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// Load and display GPX files
{% for gpx_file in post.gpx_files %}
loadGPXFile('{{ gpx_file.get_url() }}');
{% endfor %}
});
function loadGPXFile(gpxUrl) {
fetch(gpxUrl)
.then(response => response.text())
.then(gpxContent => {
const parser = new DOMParser();
const gpxDoc = parser.parseFromString(gpxContent, 'application/xml');
// Parse track points
const trackPoints = [];
const trkpts = gpxDoc.getElementsByTagName('trkpt');
let totalDistance = 0;
let elevationGain = 0;
let maxElevation = 0;
let previousPoint = null;
for (let i = 0; i < trkpts.length; i++) {
const lat = parseFloat(trkpts[i].getAttribute('lat'));
const lon = parseFloat(trkpts[i].getAttribute('lon'));
const eleElement = trkpts[i].getElementsByTagName('ele')[0];
const elevation = eleElement ? parseFloat(eleElement.textContent) : 0;
trackPoints.push([lat, lon]);
if (elevation > maxElevation) {
maxElevation = elevation;
}
if (previousPoint) {
// Calculate distance
const distance = calculateDistance(previousPoint.lat, previousPoint.lon, lat, lon);
totalDistance += distance;
// Calculate elevation gain
if (elevation > previousPoint.elevation) {
elevationGain += (elevation - previousPoint.elevation);
}
}
previousPoint = { lat: lat, lon: lon, elevation: elevation };
}
// Add track to map
if (trackPoints.length > 0) {
const polyline = L.polyline(trackPoints, {
color: '#e74c3c',
weight: 4,
opacity: 0.8
}).addTo(map);
// Fit map to track
map.fitBounds(polyline.getBounds(), { padding: [20, 20] });
// Add start and end markers
const startIcon = L.divIcon({
html: '<div class="w-6 h-6 bg-green-500 rounded-full border-2 border-white flex items-center justify-center text-white text-xs font-bold">S</div>',
className: 'custom-div-icon',
iconSize: [24, 24],
iconAnchor: [12, 12]
});
const endIcon = L.divIcon({
html: '<div class="w-6 h-6 bg-red-500 rounded-full border-2 border-white flex items-center justify-center text-white text-xs font-bold">E</div>',
className: 'custom-div-icon',
iconSize: [24, 24],
iconAnchor: [12, 12]
});
L.marker(trackPoints[0], { icon: startIcon })
.bindPopup('Start Point')
.addTo(map);
L.marker(trackPoints[trackPoints.length - 1], { icon: endIcon })
.bindPopup('End Point')
.addTo(map);
}
// Update statistics
document.getElementById('distance').textContent = totalDistance.toFixed(1);
document.getElementById('elevation-gain').textContent = Math.round(elevationGain);
document.getElementById('max-elevation').textContent = Math.round(maxElevation);
document.getElementById('waypoints').textContent = trackPoints.length;
})
.catch(error => {
console.error('Error loading GPX file:', error);
});
}
function calculateDistance(lat1, lon1, lat2, lon2) {
const R = 6371; // Earth's radius in km
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat/2) * Math.sin(dLat/2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon/2) * Math.sin(dLon/2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1-a));
return R * c;
}
{% endif %}
// Image modal functionality
function openImageModal(imageSrc, imageTitle) {
document.getElementById('modalImage').src = imageSrc;
document.getElementById('modalCaption').textContent = imageTitle;
document.getElementById('imageModal').classList.remove('hidden');
}
function closeImageModal() {
document.getElementById('imageModal').classList.add('hidden');
}
// Close modal on click outside
document.getElementById('imageModal').addEventListener('click', function(e) {
if (e.target === this) {
closeImageModal();
}
});
// Close modal on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeImageModal();
}
});
// Like functionality
function toggleLike(postId) {
fetch(`/community/post/${postId}/like`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
const likeBtn = document.querySelector('button[onclick*="toggleLike"]');
const likeText = document.getElementById('like-text');
if (data.liked) {
likeBtn.classList.remove('from-pink-600', 'to-red-600', 'hover:from-pink-700', 'hover:to-red-700');
likeBtn.classList.add('from-red-600', 'to-pink-600', 'hover:from-red-700', 'hover:to-pink-700');
likeText.textContent = 'Liked!';
} else {
likeBtn.classList.remove('from-red-600', 'to-pink-600', 'hover:from-red-700', 'hover:to-pink-700');
likeBtn.classList.add('from-pink-600', 'to-red-600', 'hover:from-pink-700', 'hover:to-red-700');
likeText.textContent = 'Like this Adventure';
}
// Update like count in meta section
const likeCountSpan = document.querySelector('.bg-pink-500\\/20');
if (likeCountSpan) {
likeCountSpan.innerHTML = `<i class="fas fa-heart mr-1"></i> ${data.count} likes`;
}
})
.catch(error => {
console.error('Error:', error);
alert('Please log in to like posts');
});
}
</script>
{% endblock %}

View File

@@ -68,6 +68,92 @@
</div>
</div>
<!-- Change Password Card -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden mb-8">
<div class="cursor-pointer transition-all duration-200 hover:bg-gray-50" onclick="togglePasswordCard()">
<div class="p-6">
<div class="flex items-center justify-between">
<div class="flex items-center">
<i class="fas fa-key text-3xl text-purple-600 mr-4"></i>
<div>
<h3 class="text-xl font-bold text-gray-900">Change Password</h3>
<p class="text-gray-600">Update your account password for security</p>
</div>
</div>
<i id="passwordCardToggle" class="fas fa-chevron-down text-gray-400 text-xl transition-transform duration-200"></i>
</div>
</div>
</div>
<!-- Password Change Form (Initially Hidden) -->
<div id="passwordChangeForm" class="hidden border-t border-gray-200">
<div class="p-6 bg-gray-50">
<form id="changePasswordForm" method="POST" action="{{ url_for('auth.change_password') }}" class="space-y-4">
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<!-- Current Password -->
<div>
<label for="current_password" class="block text-sm font-semibold text-gray-700 mb-2">
<i class="fas fa-lock mr-1"></i>Current Password
</label>
<input type="password"
id="current_password"
name="current_password"
required
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all duration-200"
placeholder="Enter current password">
</div>
<!-- New Password -->
<div>
<label for="new_password" class="block text-sm font-semibold text-gray-700 mb-2">
<i class="fas fa-key mr-1"></i>New Password
</label>
<input type="password"
id="new_password"
name="new_password"
required
minlength="6"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all duration-200"
placeholder="Enter new password">
</div>
<!-- Confirm New Password -->
<div>
<label for="confirm_password" class="block text-sm font-semibold text-gray-700 mb-2">
<i class="fas fa-check-circle mr-1"></i>Confirm Password
</label>
<input type="password"
id="confirm_password"
name="confirm_password"
required
minlength="6"
class="w-full px-4 py-3 border border-gray-300 rounded-lg focus:ring-2 focus:ring-purple-500 focus:border-transparent transition-all duration-200"
placeholder="Confirm new password">
</div>
</div>
<!-- Password Requirements -->
<div class="bg-blue-50 border border-blue-200 rounded-lg p-4">
<h4 class="font-semibold text-blue-800 mb-2">Password Requirements:</h4>
<ul class="text-sm text-blue-700 space-y-1">
<li><i class="fas fa-check text-green-500 mr-1"></i>At least 6 characters long</li>
<li><i class="fas fa-info-circle text-blue-500 mr-1"></i>Use a unique password you don't use elsewhere</li>
<li><i class="fas fa-shield-alt text-green-500 mr-1"></i>Consider using a mix of letters, numbers, and symbols</li>
</ul>
</div>
<!-- Submit Button -->
<div class="flex justify-end">
<button type="submit"
class="inline-flex items-center px-6 py-3 bg-gradient-to-r from-purple-600 to-blue-600 text-white font-semibold rounded-lg hover:from-purple-700 hover:to-blue-700 transition-all duration-200 transform hover:scale-105">
<i class="fas fa-save mr-2"></i>Update Password
</button>
</div>
</form>
</div>
</div>
</div>
<!-- Adventures Collection Header -->
<div class="bg-white rounded-2xl shadow-xl p-6 mb-8">
<div class="flex items-center">
@@ -280,6 +366,67 @@ function closeDeleteModal() {
document.getElementById('deleteModal').classList.add('hidden');
}
// Password card toggle functionality
function togglePasswordCard() {
const form = document.getElementById('passwordChangeForm');
const toggle = document.getElementById('passwordCardToggle');
if (form && toggle) {
if (form.classList.contains('hidden')) {
form.classList.remove('hidden');
toggle.classList.remove('fa-chevron-down');
toggle.classList.add('fa-chevron-up');
} else {
form.classList.add('hidden');
toggle.classList.remove('fa-chevron-up');
toggle.classList.add('fa-chevron-down');
}
}
}
// Make sure DOM is loaded before attaching event listeners
document.addEventListener('DOMContentLoaded', function() {
// Ensure the toggle function is available globally
window.togglePasswordCard = togglePasswordCard;
});
// Password change form validation
document.getElementById('changePasswordForm').addEventListener('submit', function(e) {
const newPassword = document.getElementById('new_password').value;
const confirmPassword = document.getElementById('confirm_password').value;
if (newPassword !== confirmPassword) {
e.preventDefault();
alert('New password and confirm password do not match. Please try again.');
document.getElementById('confirm_password').focus();
return false;
}
if (newPassword.length < 6) {
e.preventDefault();
alert('Password must be at least 6 characters long.');
document.getElementById('new_password').focus();
return false;
}
});
// Real-time password confirmation validation
document.getElementById('confirm_password').addEventListener('input', function() {
const newPassword = document.getElementById('new_password').value;
const confirmPassword = this.value;
if (confirmPassword && newPassword !== confirmPassword) {
this.style.borderColor = '#ef4444';
this.style.backgroundColor = '#fef2f2';
} else if (confirmPassword && newPassword === confirmPassword) {
this.style.borderColor = '#10b981';
this.style.backgroundColor = '#f0fdf4';
} else {
this.style.borderColor = '#d1d5db';
this.style.backgroundColor = '#ffffff';
}
});
// Close modal when clicking outside
document.getElementById('deleteModal').addEventListener('click', function(e) {
if (e.target === this) {

3
app/utils/__init__.py Normal file
View File

@@ -0,0 +1,3 @@
"""
Utility functions package
"""

View File

@@ -0,0 +1,38 @@
import os
from app import db
from app.models import Post, GPXFile
def clean_orphan_post_media(media_root='app/static/media/posts'):
"""
Remove folders in app/static/media/posts that do not have a corresponding Post in the database.
"""
# Get all valid media_folder names from the database
valid_folders = set(post.media_folder for post in Post.query.all() if post.media_folder)
# List all folders in the media root
for folder in os.listdir(media_root):
folder_path = os.path.join(media_root, folder)
if os.path.isdir(folder_path) and folder not in valid_folders:
print(f"Deleting orphaned media folder: {folder_path}")
# Recursively delete the folder and its contents
for root, dirs, files in os.walk(folder_path, topdown=False):
for name in files:
os.remove(os.path.join(root, name))
for name in dirs:
os.rmdir(os.path.join(root, name))
os.rmdir(folder_path)
# --- Remove orphaned GPXFile records ---
# Get all valid post IDs
valid_post_ids = set(post.id for post in Post.query.all())
# Find GPXFile records whose post_id is not in valid_post_ids
orphaned_gpx_files = GPXFile.query.filter(~GPXFile.post_id.in_(valid_post_ids)).all()
if orphaned_gpx_files:
print(f"Deleting {len(orphaned_gpx_files)} orphaned GPXFile records from the database.")
for gpx in orphaned_gpx_files:
db.session.delete(gpx)
db.session.commit()
else:
print("No orphaned GPXFile records found.")
print("Orphaned media and GPXFile cleanup complete.")

View File

@@ -23,6 +23,7 @@ class Config:
ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'webp', 'gpx'}
# Email Configuration
MAIL_ENABLED = os.environ.get('MAIL_ENABLED', 'true').lower() in ['true', 'on', '1']
MAIL_SERVER = os.environ.get('MAIL_SERVER') or 'smtp.gmail.com'
MAIL_PORT = int(os.environ.get('MAIL_PORT') or 587)
MAIL_USE_TLS = os.environ.get('MAIL_USE_TLS', 'true').lower() in ['true', 'on', '1']

25
app/utils/create_admin.py Normal file
View File

@@ -0,0 +1,25 @@
import os
from app import create_app
from app.extensions import db
from app.models import User
def create_admin():
app = create_app()
with app.app_context():
admin_email = os.environ.get('ADMIN_EMAIL')
admin_nickname = os.environ.get('ADMIN_NICKNAME')
admin_password = os.environ.get('ADMIN_PASSWORD')
if not (admin_email and admin_nickname and admin_password):
print("Missing ADMIN_EMAIL, ADMIN_NICKNAME, or ADMIN_PASSWORD in environment.")
return
if User.query.filter_by(email=admin_email).first():
print(f"Admin with email {admin_email} already exists.")
return
user = User(nickname=admin_nickname, email=admin_email, is_admin=True, is_active=True)
user.set_password(admin_password)
db.session.add(user)
db.session.commit()
print(f"Admin user {admin_nickname} <{admin_email}> created.")
if __name__ == "__main__":
create_admin()

557
app/utils/gpx_processor.py Normal file
View File

@@ -0,0 +1,557 @@
"""
GPX file processing utilities for extracting route statistics and creating map routes
"""
import xml.etree.ElementTree as ET
import math
import os
import json
import gpxpy
from typing import Dict, Optional, Tuple, List
from app import db
def calculate_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
"""
Calculate the great circle distance between two points on the earth (specified in decimal degrees)
Returns distance in kilometers
"""
# Convert decimal degrees to radians
lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2])
# Haversine formula
dlat = lat2 - lat1
dlon = lon2 - lon1
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
c = 2 * math.asin(math.sqrt(a))
# Radius of earth in kilometers
r = 6371
return c * r
def extract_gpx_statistics(file_path: str) -> Optional[Dict]:
"""
Extract statistics from a GPX file
Returns:
Dictionary with keys: total_distance, elevation_gain, max_elevation,
min_elevation, total_points, or None if file cannot be processed
"""
if not os.path.exists(file_path):
return None
try:
# Parse GPX file
tree = ET.parse(file_path)
root = tree.getroot()
# Handle GPX namespace
namespace = {'gpx': 'http://www.topografix.com/GPX/1/1'}
if not root.tag.endswith('gpx'):
# Try without namespace
namespace = {}
# Find track points
track_points = []
# Look for track points in tracks
tracks = root.findall('.//gpx:trk', namespace) if namespace else root.findall('.//trk')
for track in tracks:
segments = track.findall('.//gpx:trkseg', namespace) if namespace else track.findall('.//trkseg')
for segment in segments:
points = segment.findall('.//gpx:trkpt', namespace) if namespace else segment.findall('.//trkpt')
for point in points:
lat = float(point.get('lat'))
lon = float(point.get('lon'))
# Get elevation if available
ele_elem = point.find('gpx:ele', namespace) if namespace else point.find('ele')
elevation = float(ele_elem.text) if ele_elem is not None and ele_elem.text else 0.0
track_points.append({
'lat': lat,
'lon': lon,
'elevation': elevation
})
# Also look for waypoints if no track points found
if not track_points:
waypoints = root.findall('.//gpx:wpt', namespace) if namespace else root.findall('.//wpt')
for point in waypoints:
lat = float(point.get('lat'))
lon = float(point.get('lon'))
ele_elem = point.find('gpx:ele', namespace) if namespace else point.find('ele')
elevation = float(ele_elem.text) if ele_elem is not None and ele_elem.text else 0.0
track_points.append({
'lat': lat,
'lon': lon,
'elevation': elevation
})
# Also look for route points if no track points or waypoints found
if not track_points:
routes = root.findall('.//gpx:rte', namespace) if namespace else root.findall('.//rte')
for route in routes:
route_points = route.findall('.//gpx:rtept', namespace) if namespace else route.findall('.//rtept')
for point in route_points:
lat = float(point.get('lat'))
lon = float(point.get('lon'))
ele_elem = point.find('gpx:ele', namespace) if namespace else point.find('ele')
elevation = float(ele_elem.text) if ele_elem is not None and ele_elem.text else 0.0
track_points.append({
'lat': lat,
'lon': lon,
'elevation': elevation
})
if not track_points:
return {
'total_distance': 0.0,
'elevation_gain': 0.0,
'max_elevation': 0.0,
'min_elevation': 0.0,
'total_points': 0
}
# Calculate statistics
total_distance = 0.0
elevation_gain = 0.0
elevations = [point['elevation'] for point in track_points if point['elevation'] > 0]
# Calculate distance and elevation gain
for i in range(1, len(track_points)):
current = track_points[i]
previous = track_points[i-1]
# Distance
distance = calculate_distance(
previous['lat'], previous['lon'],
current['lat'], current['lon']
)
total_distance += distance
# Elevation gain (only uphill)
if current['elevation'] > 0 and previous['elevation'] > 0:
elevation_diff = current['elevation'] - previous['elevation']
if elevation_diff > 0:
elevation_gain += elevation_diff
# Elevation statistics
max_elevation = max(elevations) if elevations else 0.0
min_elevation = min(elevations) if elevations else 0.0
return {
'total_distance': round(total_distance, 2),
'elevation_gain': round(elevation_gain, 1),
'max_elevation': round(max_elevation, 1),
'min_elevation': round(min_elevation, 1),
'total_points': len(track_points)
}
except Exception as e:
print(f"Error processing GPX file {file_path}: {e}")
return None
def process_gpx_file(gpx_file_record) -> bool:
"""
Process a GPXFile record and update its statistics
Args:
gpx_file_record: GPXFile model instance
Returns:
True if processing was successful, False otherwise
"""
from flask import current_app
# Build file path
if gpx_file_record.post.media_folder:
file_path = os.path.join(
current_app.root_path, 'static', 'media', 'posts',
gpx_file_record.post.media_folder, 'gpx', gpx_file_record.filename
)
else:
file_path = os.path.join(
current_app.root_path, 'static', 'uploads', 'gpx', gpx_file_record.filename
)
# Extract statistics
stats = extract_gpx_statistics(file_path)
if stats is None:
return False
# Update the record
gpx_file_record.total_distance = stats['total_distance']
gpx_file_record.elevation_gain = stats['elevation_gain']
gpx_file_record.max_elevation = stats['max_elevation']
gpx_file_record.min_elevation = stats['min_elevation']
gpx_file_record.total_points = stats['total_points']
return True
def simplify_coordinates(coordinates: List[List[float]], tolerance: float = 0.0001) -> List[List[float]]:
"""
Simplify coordinates using Douglas-Peucker algorithm
Returns a reduced set of points while maintaining the general shape
"""
if len(coordinates) <= 2:
return coordinates
def perpendicular_distance(point, line_start, line_end):
"""Calculate perpendicular distance from point to line"""
if line_start == line_end:
return ((point[0] - line_start[0]) ** 2 + (point[1] - line_start[1]) ** 2) ** 0.5
# Calculate the perpendicular distance
A = point[0] - line_start[0]
B = point[1] - line_start[1]
C = line_end[0] - line_start[0]
D = line_end[1] - line_start[1]
dot = A * C + B * D
len_sq = C * C + D * D
if len_sq == 0:
return (A * A + B * B) ** 0.5
param = dot / len_sq
if param < 0:
xx = line_start[0]
yy = line_start[1]
elif param > 1:
xx = line_end[0]
yy = line_end[1]
else:
xx = line_start[0] + param * C
yy = line_start[1] + param * D
dx = point[0] - xx
dy = point[1] - yy
return (dx * dx + dy * dy) ** 0.5
def douglas_peucker(points, tolerance):
"""Douglas-Peucker algorithm implementation"""
if len(points) <= 2:
return points
# Find the point with maximum distance
max_dist = 0
index = 0
for i in range(1, len(points) - 1):
dist = perpendicular_distance(points[i], points[0], points[-1])
if dist > max_dist:
index = i
max_dist = dist
# If max distance is greater than tolerance, recursively simplify
if max_dist > tolerance:
# Recursive call
rec_results1 = douglas_peucker(points[:index + 1], tolerance)
rec_results2 = douglas_peucker(points[index:], tolerance)
# Build the result list
result = rec_results1[:-1] + rec_results2
return result
else:
return [points[0], points[-1]]
return douglas_peucker(coordinates, tolerance)
def calculate_bounds(coordinates: List[List[float]]) -> Optional[Dict]:
"""Calculate bounding box for coordinates"""
if not coordinates:
return None
lats = [coord[0] for coord in coordinates]
lngs = [coord[1] for coord in coordinates]
return {
'north': max(lats),
'south': min(lats),
'east': max(lngs),
'west': min(lngs)
}
def create_map_route_from_gpx(gpx_file_id: int) -> bool:
"""
Process a GPX file and create/update corresponding MapRoute entry
"""
try:
from app.models import MapRoute, GPXFile, Post
from flask import current_app
# Get the GPX file record
gpx_file = GPXFile.query.get(gpx_file_id)
if not gpx_file:
print(f"GPX file with ID {gpx_file_id} not found")
return False
# Get the file path
if gpx_file.post.media_folder:
file_path = os.path.join(
current_app.root_path, 'static', 'media', 'posts',
gpx_file.post.media_folder, 'gpx', gpx_file.filename
)
else:
file_path = os.path.join(
current_app.root_path, 'static', 'uploads', 'gpx', gpx_file.filename
)
if not os.path.exists(file_path):
print(f"GPX file not found: {file_path}")
return False
# Parse GPX file using gpxpy for better coordinate extraction
try:
with open(file_path, 'r', encoding='utf-8') as f:
gpx = gpxpy.parse(f)
except Exception as e:
print(f"Error parsing GPX file with gpxpy: {e}")
return False
# Extract coordinates from all tracks and segments
all_coordinates = []
total_distance = 0
elevations = []
for track in gpx.tracks:
for segment in track.segments:
prev_point = None
for point in segment.points:
coord = [point.latitude, point.longitude]
all_coordinates.append(coord)
if point.elevation is not None:
elevations.append(point.elevation)
# Calculate distance
if prev_point:
distance = prev_point.distance_2d(point)
if distance:
total_distance += distance
prev_point = point
# If no track points, try routes
if not all_coordinates:
for route in gpx.routes:
prev_point = None
for point in route.points:
coord = [point.latitude, point.longitude]
all_coordinates.append(coord)
if point.elevation is not None:
elevations.append(point.elevation)
# Calculate distance
if prev_point:
distance = prev_point.distance_2d(point)
if distance:
total_distance += distance
prev_point = point
# If no track or route points, try waypoints
if not all_coordinates:
for waypoint in gpx.waypoints:
coord = [waypoint.latitude, waypoint.longitude]
all_coordinates.append(coord)
if waypoint.elevation is not None:
elevations.append(waypoint.elevation)
if not all_coordinates:
print("No coordinates found in GPX file")
return False
# Calculate statistics
elevation_gain = 0
if elevations:
for i in range(1, len(elevations)):
gain = elevations[i] - elevations[i-1]
if gain > 0:
elevation_gain += gain
# Simplify coordinates for overview map
simplified_coords = simplify_coordinates(all_coordinates, tolerance=0.001)
# Calculate bounds
bounds = calculate_bounds(all_coordinates)
if not bounds:
print("Could not calculate bounds for coordinates")
return False
# Create or update MapRoute
existing_route = MapRoute.query.filter_by(post_id=gpx_file.post_id).first()
if existing_route:
# Update existing route
map_route = existing_route
else:
# Create new route
map_route = MapRoute(post_id=gpx_file.post_id, gpx_file_id=gpx_file_id)
# Set route data
map_route.coordinates = json.dumps(all_coordinates)
map_route.simplified_coordinates = json.dumps(simplified_coords)
# Set start and end points
map_route.start_latitude = all_coordinates[0][0]
map_route.start_longitude = all_coordinates[0][1]
map_route.end_latitude = all_coordinates[-1][0]
map_route.end_longitude = all_coordinates[-1][1]
# Set bounds
map_route.bounds_north = bounds['north']
map_route.bounds_south = bounds['south']
map_route.bounds_east = bounds['east']
map_route.bounds_west = bounds['west']
# Set statistics
map_route.total_distance = total_distance / 1000 if total_distance else 0 # Convert to kilometers
map_route.elevation_gain = elevation_gain
map_route.max_elevation = max(elevations) if elevations else 0
map_route.min_elevation = min(elevations) if elevations else 0
map_route.total_points = len(all_coordinates)
map_route.simplified_points = len(simplified_coords)
# Save to database
if not existing_route:
db.session.add(map_route)
db.session.commit()
print(f"Successfully created map route for GPX file {gpx_file.filename} (Post {gpx_file.post_id})")
print(f"- Total points: {len(all_coordinates)}")
print(f"- Simplified points: {len(simplified_coords)}")
print(f"- Distance: {map_route.total_distance:.2f} km")
print(f"- Elevation gain: {map_route.elevation_gain:.0f} m")
return True
except Exception as e:
db.session.rollback()
print(f"Error creating map route for GPX file {gpx_file_id}: {str(e)}")
return False
def process_post_approval(post_id: int) -> bool:
"""
Process all GPX files for a post when it gets approved
Creates map routes for efficient map loading
"""
try:
from app.models import Post, GPXFile
post = Post.query.get(post_id)
if not post:
print(f"Post with ID {post_id} not found")
return False
# Get all GPX files for this post
gpx_files = GPXFile.query.filter_by(post_id=post_id).all()
if not gpx_files:
print(f"No GPX files found for post {post_id}")
return True # Not an error if no GPX files
# Process the first GPX file (assuming one route per post)
# If multiple files exist, you might want to merge them or process separately
gpx_file = gpx_files[0]
success = create_map_route_from_gpx(gpx_file.id)
if success:
print(f"Successfully processed post {post_id} approval - map route created")
else:
print(f"Failed to create map route for post {post_id}")
return success
except Exception as e:
print(f"Error processing post approval for post {post_id}: {str(e)}")
return False
def get_all_map_routes() -> List[Dict]:
"""
Get all map routes for the community map
Returns simplified data optimized for map display
"""
try:
from app.models import MapRoute, Post
routes = MapRoute.query.join(Post).filter(Post.published == True).all()
map_data = []
for route in routes:
try:
map_data.append({
'id': route.id,
'post_id': route.post_id,
'post_title': route.post.title,
'post_author': route.post.author.nickname,
'coordinates': route.get_simplified_coordinates_json(),
'start_point': route.get_start_point(),
'end_point': route.get_end_point(),
'bounds': route.get_bounds(),
'stats': {
'distance': route.total_distance,
'elevation_gain': route.elevation_gain,
'max_elevation': route.max_elevation
}
})
except Exception as e:
print(f"Error processing route {route.id}: {e}")
continue
return map_data
except Exception as e:
print(f"Error getting map routes: {str(e)}")
return []
def get_post_route_details(post_id: int) -> Optional[Dict]:
"""
Get detailed route data for a specific post
Returns full coordinate data for detailed view
"""
try:
from app.models import MapRoute
route = MapRoute.query.filter_by(post_id=post_id).first()
if not route:
return None
return {
'id': route.id,
'post_id': route.post_id,
'coordinates': route.get_coordinates_json(),
'simplified_coordinates': route.get_simplified_coordinates_json(),
'start_point': route.get_start_point(),
'end_point': route.get_end_point(),
'bounds': route.get_bounds(),
'stats': {
'distance': route.total_distance,
'elevation_gain': route.elevation_gain,
'max_elevation': route.max_elevation,
'min_elevation': route.min_elevation,
'total_points': route.total_points,
'simplified_points': route.simplified_points
}
}
except Exception as e:
print(f"Error getting route details for post {post_id}: {str(e)}")
return None

228
app/utils/manage_routes.py Executable file
View File

@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""
Map Route Management Script
Utility for managing map routes in the database
"""
import sys
import os
from flask import Flask
# Add the project root to Python path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app import create_app, db
from app.models import Post, GPXFile, MapRoute, User
from app.utils.gpx_processor import create_map_route_from_gpx, process_post_approval
def show_help():
"""Show available commands"""
print("""
🗺️ Map Route Management Script
Available commands:
list - List all map routes
create <post_id> - Create map route for a specific post
recreate <post_id> - Recreate map route for a specific post
recreate-all - Recreate all map routes
stats - Show database statistics
cleanup - Remove map routes for unpublished posts
help - Show this help message
Examples:
python manage_routes.py list
python manage_routes.py create 1
python manage_routes.py recreate-all
python manage_routes.py stats
""")
def list_routes():
"""List all map routes"""
app = create_app()
with app.app_context():
routes = MapRoute.query.join(Post).all()
if not routes:
print("❌ No map routes found")
return
print(f"📍 Found {len(routes)} map routes:\n")
print("ID | Post | Title | Author | Published | Points | Distance")
print("-" * 70)
for route in routes:
status = "" if route.post.published else ""
print(f"{route.id:2d} | {route.post_id:4d} | {route.post.title[:20]:20s} | {route.post.author.nickname[:10]:10s} | {status:2s} | {route.simplified_points:6d} | {route.total_distance:7.2f} km")
def create_route(post_id):
"""Create map route for a specific post"""
app = create_app()
with app.app_context():
post = Post.query.get(post_id)
if not post:
print(f"❌ Post {post_id} not found")
return False
gpx_files = GPXFile.query.filter_by(post_id=post_id).all()
if not gpx_files:
print(f"❌ No GPX files found for post {post_id}")
return False
print(f"🔄 Creating map route for post {post_id}: {post.title}")
success = create_map_route_from_gpx(gpx_files[0].id)
if success:
print(f"✅ Successfully created map route for post {post_id}")
else:
print(f"❌ Failed to create map route for post {post_id}")
return success
def recreate_route(post_id):
"""Recreate map route for a specific post"""
app = create_app()
with app.app_context():
# Delete existing route
existing = MapRoute.query.filter_by(post_id=post_id).first()
if existing:
db.session.delete(existing)
db.session.commit()
print(f"🗑️ Deleted existing map route for post {post_id}")
# Create new route
return create_route(post_id)
def recreate_all_routes():
"""Recreate all map routes"""
app = create_app()
with app.app_context():
# Get all posts with GPX files
posts_with_gpx = db.session.query(Post).join(GPXFile).distinct().all()
if not posts_with_gpx:
print("❌ No posts with GPX files found")
return
print(f"🔄 Recreating map routes for {len(posts_with_gpx)} posts...")
# Delete all existing routes
MapRoute.query.delete()
db.session.commit()
print("🗑️ Deleted all existing map routes")
success_count = 0
error_count = 0
for post in posts_with_gpx:
print(f"\n🔄 Processing post {post.id}: {post.title}")
success = process_post_approval(post.id)
if success:
success_count += 1
else:
error_count += 1
print(f"\n📊 Results:")
print(f"✅ Successfully processed: {success_count}")
print(f"❌ Errors: {error_count}")
print(f"📍 Total posts: {len(posts_with_gpx)}")
def show_stats():
"""Show database statistics"""
app = create_app()
with app.app_context():
total_posts = Post.query.count()
published_posts = Post.query.filter_by(published=True).count()
posts_with_gpx = db.session.query(Post).join(GPXFile).distinct().count()
total_routes = MapRoute.query.count()
published_routes = MapRoute.query.join(Post).filter(Post.published == True).count()
print("📊 Database Statistics:")
print(f" 📝 Total posts: {total_posts}")
print(f" ✅ Published posts: {published_posts}")
print(f" 🗺️ Posts with GPX: {posts_with_gpx}")
print(f" 📍 Total map routes: {total_routes}")
print(f" 🌍 Published routes: {published_routes}")
if total_routes > 0:
routes = MapRoute.query.all()
total_points = sum(r.total_points for r in routes)
simplified_points = sum(r.simplified_points for r in routes)
total_distance = sum(r.total_distance for r in routes)
print(f"\n🎯 Route Statistics:")
print(f" 📊 Total coordinate points: {total_points:,}")
print(f" 🎯 Simplified points: {simplified_points:,}")
print(f" 📏 Total distance: {total_distance:.2f} km")
print(f" 🗜️ Compression ratio: {simplified_points/total_points*100:.1f}%")
def cleanup_routes():
"""Remove map routes for unpublished posts"""
app = create_app()
with app.app_context():
unpublished_routes = MapRoute.query.join(Post).filter(Post.published == False).all()
if not unpublished_routes:
print("✅ No unpublished routes to clean up")
return
print(f"🗑️ Found {len(unpublished_routes)} routes for unpublished posts")
for route in unpublished_routes:
print(f" Removing route for post {route.post_id}: {route.post.title}")
db.session.delete(route)
db.session.commit()
print(f"✅ Cleaned up {len(unpublished_routes)} routes")
def main():
if len(sys.argv) < 2:
show_help()
return
command = sys.argv[1].lower()
if command == 'help':
show_help()
elif command == 'list':
list_routes()
elif command == 'create':
if len(sys.argv) < 3:
print("❌ Please provide a post ID")
print("Usage: python manage_routes.py create <post_id>")
return
try:
post_id = int(sys.argv[2])
create_route(post_id)
except ValueError:
print("❌ Invalid post ID. Please provide a number.")
elif command == 'recreate':
if len(sys.argv) < 3:
print("❌ Please provide a post ID")
print("Usage: python manage_routes.py recreate <post_id>")
return
try:
post_id = int(sys.argv[2])
recreate_route(post_id)
except ValueError:
print("❌ Invalid post ID. Please provide a number.")
elif command == 'recreate-all':
recreate_all_routes()
elif command == 'stats':
show_stats()
elif command == 'cleanup':
cleanup_routes()
else:
print(f"❌ Unknown command: {command}")
show_help()
if __name__ == "__main__":
main()

19
app/utils/token.py Normal file
View File

@@ -0,0 +1,19 @@
import os
from itsdangerous import URLSafeTimedSerializer
from flask import current_app
def get_serializer():
secret = os.environ.get('RESET_TOKEN_SECRET') or current_app.config.get('SECRET_KEY')
return URLSafeTimedSerializer(secret)
def generate_reset_token(email):
s = get_serializer()
return s.dumps(email, salt='password-reset')
def verify_reset_token(token, max_age=3600):
s = get_serializer()
try:
email = s.loads(token, salt='password-reset', max_age=max_age)
return email
except Exception:
return None

View File

@@ -1,46 +1,17 @@
version: '3.8'
services:
app:
build: .
build:
context: .
dockerfile: Dockerfile
ports:
- "5000:5000"
- "8100:5000"
environment:
- FLASK_ENV=production
- DATABASE_URL=postgresql://moto_user:moto_pass@db:5432/moto_adventure
- SECRET_KEY=your-super-secret-key-change-this
- MAIL_SERVER=smtp.gmail.com
- MAIL_PORT=587
- MAIL_USE_TLS=true
- MAIL_USERNAME=your-email@gmail.com
- MAIL_PASSWORD=your-app-password
- FLASK_CONFIG=production
- DATABASE_URL=sqlite:////data/moto_adventure.db
- SECRET_KEY=ana_are_mere_si-si-pere_cat-cuprinde_in_cos
working_dir: /opt/moto_site
volumes:
- ./uploads:/opt/site/flask-moto-adventure/uploads
depends_on:
- db
- ./data:/data # Database persistence
- ./uploads:/opt/moto_site/uploads # File uploads persistence
- ./app/static/media:/opt/moto_site/app/static/media # Media files persistence
restart: unless-stopped
db:
image: postgres:15
environment:
- POSTGRES_DB=moto_adventure
- POSTGRES_USER=moto_user
- POSTGRES_PASSWORD=moto_pass
volumes:
- postgres_data:/var/lib/postgresql/data
restart: unless-stopped
nginx:
image: nginx:alpine
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
- ./uploads:/opt/site/flask-moto-adventure/uploads:ro
depends_on:
- app
restart: unless-stopped
volumes:
postgres_data:

44
fix_gpx_statistics.py Normal file
View File

@@ -0,0 +1,44 @@
#!/usr/bin/env python3
"""
Script to reprocess all GPX files and update their statistics in the database
"""
from app import create_app, db
from app.models import GPXFile
from app.utils.gpx_processor import process_gpx_file
import os
def fix_gpx_statistics():
"""Reprocess all GPX files to update their statistics"""
app = create_app()
with app.app_context():
gpx_files = GPXFile.query.all()
print(f"Found {len(gpx_files)} GPX files to process...")
updated_count = 0
for gpx_file in gpx_files:
print(f"\nProcessing: {gpx_file.filename}")
print(f"Post: {gpx_file.post.title}")
print(f"Media folder: {gpx_file.post.media_folder}")
# Try to process the file
if process_gpx_file(gpx_file):
print(f"✓ Updated - Distance: {gpx_file.total_distance}km, "
f"Elevation: {gpx_file.elevation_gain}m, "
f"Points: {gpx_file.total_points}")
updated_count += 1
else:
print(f"✗ Failed to process {gpx_file.filename}")
# Commit all changes
db.session.commit()
print(f"\n=== Summary ===")
print(f"Total GPX files: {len(gpx_files)}")
print(f"Successfully updated: {updated_count}")
print(f"Failed: {len(gpx_files) - updated_count}")
if __name__ == "__main__":
fix_gpx_statistics()

View File

@@ -0,0 +1,39 @@
"""
Database migration to add category field to chat_rooms table
"""
from app.extensions import db
def upgrade():
"""Add category field to chat_rooms table"""
try:
# Add the category column
db.engine.execute("""
ALTER TABLE chat_rooms
ADD COLUMN category VARCHAR(50) DEFAULT 'general'
""")
# Update existing rooms to have category based on room_type
db.engine.execute("""
UPDATE chat_rooms
SET category = CASE
WHEN room_type = 'general' THEN 'general'
WHEN room_type = 'post_discussion' THEN 'general'
WHEN room_type = 'admin_support' THEN 'technical'
WHEN room_type = 'password_reset' THEN 'technical'
ELSE 'general'
END
""")
print("✅ Successfully added category field to chat_rooms table")
except Exception as e:
print(f"❌ Error adding category field: {e}")
# If column already exists, that's fine
if "duplicate column name" in str(e).lower() or "already exists" in str(e).lower():
print(" Category column already exists")
else:
raise
if __name__ == "__main__":
upgrade()

160
migrations/add_chat_system.py Executable file
View File

@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""
Chat System Database Migration
Adds chat functionality to the Moto Adventure application.
"""
import os
import sys
from datetime import datetime
# Add the app directory to the Python path
sys.path.insert(0, '/opt/site')
from app import create_app, db
from app.models import ChatRoom, ChatMessage, ChatParticipant
def run_migration():
"""Run the chat system migration"""
app = create_app()
with app.app_context():
print(f"[{datetime.now()}] Starting chat system migration...")
try:
# Create the chat tables
print("Creating chat system tables...")
db.create_all()
# Get or create system user for welcome messages and room ownership
print("Setting up system user...")
from app.models import User
system_user = User.query.filter_by(email='system@motoadventure.local').first()
if not system_user:
system_user = User(
nickname='System',
email='system@motoadventure.local',
is_admin=True,
is_active=True
)
system_user.set_password('system123!') # Random password, won't be used
db.session.add(system_user)
db.session.commit()
print(" ✓ Created system user")
# Create default chat rooms
print("Creating default chat rooms...")
# General chat room
general_room = ChatRoom.query.filter_by(name="General Discussion").first()
if not general_room:
general_room = ChatRoom(
name="General Discussion",
description="General conversation about motorcycles and adventures",
is_private=False,
is_active=True,
created_by_id=system_user.id
)
db.session.add(general_room)
print(" ✓ Created General Discussion room")
# Technical support room
support_room = ChatRoom.query.filter_by(name="Technical Support").first()
if not support_room:
support_room = ChatRoom(
name="Technical Support",
description="Get help with technical issues and app support",
is_private=False,
is_active=True,
room_type="admin_support",
created_by_id=system_user.id
)
db.session.add(support_room)
print(" ✓ Created Technical Support room")
# Route planning room
routes_room = ChatRoom.query.filter_by(name="Route Planning").first()
if not routes_room:
routes_room = ChatRoom(
name="Route Planning",
description="Discuss routes, share GPX files, and plan adventures",
is_private=False,
is_active=True,
created_by_id=system_user.id
)
db.session.add(routes_room)
print(" ✓ Created Route Planning room")
# Gear & Equipment room
gear_room = ChatRoom.query.filter_by(name="Gear & Equipment").first()
if not gear_room:
gear_room = ChatRoom(
name="Gear & Equipment",
description="Discuss motorcycle gear, equipment reviews, and recommendations",
is_private=False,
is_active=True,
created_by_id=system_user.id
)
db.session.add(gear_room)
print(" ✓ Created Gear & Equipment room")
# Commit the changes
db.session.commit()
print("✓ Default chat rooms created successfully")
# Add welcome messages to rooms
print("Adding welcome messages...")
# Add welcome messages if they don't exist
rooms_with_messages = [
(general_room, "Welcome to the General Discussion! Share your motorcycle adventures and connect with fellow riders."),
(support_room, "Welcome to Technical Support! Our administrators are here to help with any issues or questions."),
(routes_room, "Welcome to Route Planning! Share your favorite routes and discover new adventures."),
(gear_room, "Welcome to Gear & Equipment! Discuss the best gear for your motorcycle adventures.")
]
for room, message_text in rooms_with_messages:
existing_message = ChatMessage.query.filter_by(
room_id=room.id,
user_id=system_user.id,
message_type='system'
).first()
if not existing_message:
welcome_message = ChatMessage(
room_id=room.id,
user_id=system_user.id,
content=message_text,
message_type='system'
)
db.session.add(welcome_message)
db.session.commit()
print("✓ Welcome messages added")
print(f"[{datetime.now()}] Chat system migration completed successfully!")
print("\nChat System Features:")
print(" • User-to-user messaging")
print(" • Admin support channels")
print(" • Post-specific discussions")
print(" • Mobile app compatibility")
print(" • Real-time messaging")
print(" • Profanity filtering")
print(" • Message moderation")
print("\nDefault Chat Rooms:")
print(" • General Discussion")
print(" • Technical Support")
print(" • Route Planning")
print(" • Gear & Equipment")
print("\nAPI Endpoints Available:")
print(" • /api/v1/chat/* (Mobile app integration)")
print(" • /chat/* (Web interface)")
except Exception as e:
print(f"[ERROR] Migration failed: {e}")
db.session.rollback()
raise e
if __name__ == '__main__':
run_migration()

View File

@@ -1,28 +0,0 @@
"""Add media_folder to posts table
This migration adds a media_folder column to the posts table to support
organized file storage for each post.
Usage:
flask db upgrade
Revision ID: add_media_folder
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers
revision = 'add_media_folder'
down_revision = None
depends_on = None
def upgrade():
"""Add media_folder column to posts table"""
# Add the media_folder column
op.add_column('posts', sa.Column('media_folder', sa.String(100), nullable=True))
def downgrade():
"""Remove media_folder column from posts table"""
# Remove the media_folder column
op.drop_column('posts', 'media_folder')

View File

@@ -0,0 +1,66 @@
#!/usr/bin/env python3
"""
Database migration script for Password Reset System
Adds PasswordResetRequest and PasswordResetToken tables
"""
import sys
import os
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app.extensions import db
from app.models import PasswordResetRequest, PasswordResetToken
from config import Config
def create_password_reset_tables():
"""Create password reset tables"""
try:
# Create tables
db.create_all()
print("✅ Password reset tables created successfully!")
# Verify tables exist
from sqlalchemy import inspect
inspector = inspect(db.engine)
tables = inspector.get_table_names()
if 'password_reset_request' in tables:
print("✅ PasswordResetRequest table exists")
else:
print("❌ PasswordResetRequest table missing")
if 'password_reset_token' in tables:
print("✅ PasswordResetToken table exists")
else:
print("❌ PasswordResetToken table missing")
return True
except Exception as e:
print(f"❌ Error creating tables: {e}")
return False
def main():
"""Main migration function"""
print("🔄 Starting password reset system migration...")
# Import app to initialize database
from run import app
with app.app_context():
success = create_password_reset_tables()
if success:
print("✅ Migration completed successfully!")
print("\nNew features available:")
print("- Admin can view password reset requests")
print("- Admin can generate secure reset tokens")
print("- Email templates for manual sending")
print("- Token usage tracking")
print("- Request status management")
else:
print("❌ Migration failed!")
sys.exit(1)
if __name__ == '__main__':
main()

Binary file not shown.

Before

Width:  |  Height:  |  Size: 602 KiB

66
run.py
View File

@@ -1,6 +1,6 @@
import os
from app import create_app, db
from app.models import User, Post, PostImage, GPXFile, Comment, Like, PageView
from app.models import User, Post, PostImage, GPXFile, Comment, Like, PageView, MapRoute
app = create_app()
@@ -14,7 +14,8 @@ def make_shell_context():
'GPXFile': GPXFile,
'Comment': Comment,
'Like': Like,
'PageView': PageView
'PageView': PageView,
'MapRoute': MapRoute
}
@app.cli.command()
@@ -75,11 +76,72 @@ def migrate_db():
else:
print('page_views table already exists')
# Check if GPX statistics columns exist
result = db.session.execute(text('PRAGMA table_info(gpx_files)'))
columns = [row[1] for row in result.fetchall()]
new_columns = [
('total_distance', 'REAL DEFAULT 0.0'),
('elevation_gain', 'REAL DEFAULT 0.0'),
('max_elevation', 'REAL DEFAULT 0.0'),
('min_elevation', 'REAL DEFAULT 0.0'),
('total_points', 'INTEGER DEFAULT 0')
]
for column_name, column_type in new_columns:
if column_name not in columns:
db.session.execute(text(f'ALTER TABLE gpx_files ADD COLUMN {column_name} {column_type}'))
db.session.commit()
print(f'Successfully added {column_name} column to gpx_files table')
else:
print(f'{column_name} column already exists in gpx_files table')
print('Database schema is up to date')
except Exception as e:
print(f'Migration error: {e}')
db.session.rollback()
@app.cli.command()
def process_gpx_files():
"""Process existing GPX files to extract statistics."""
from app.utils.gpx_processor import process_gpx_file
gpx_files = GPXFile.query.all()
processed = 0
for gpx_file in gpx_files:
try:
if process_gpx_file(gpx_file):
processed += 1
print(f'Processed: {gpx_file.original_name}')
else:
print(f'Failed to process: {gpx_file.original_name}')
except Exception as e:
print(f'Error processing {gpx_file.original_name}: {e}')
db.session.commit()
print(f'Processed {processed}/{len(gpx_files)} GPX files')
@app.cli.command()
def set_cover_images():
"""Set cover images for posts that don't have them."""
posts = Post.query.all()
updated = 0
for post in posts:
# Check if post has a cover image
cover_image = post.images.filter_by(is_cover=True).first()
if not cover_image:
# Set the first image as cover if available
first_image = post.images.first()
if first_image:
first_image.is_cover = True
updated += 1
print(f'Set cover image for post: {post.title}')
db.session.commit()
print(f'Updated {updated} posts with cover images')
@app.cli.command()
def create_admin():
"""Create an admin user."""

View File

@@ -1,147 +0,0 @@
#!/usr/bin/env python3
"""
Test script for the media management system
"""
import os
import sys
import tempfile
from io import BytesIO
# Add the app directory to the path
sys.path.insert(0, os.path.dirname(__file__))
from app import create_app
from app.models import Post, PostImage, User
from app.extensions import db
from app.media_config import MediaConfig
from PIL import Image
def test_media_folder_creation():
"""Test that media folders are created correctly"""
app = create_app()
with app.app_context():
# Create a test user
test_user = User.query.filter_by(email='test@example.com').first()
if not test_user:
test_user = User(
nickname='testuser',
email='test@example.com'
)
test_user.set_password('testpass')
db.session.add(test_user)
db.session.commit()
# Create a test post
test_post = Post(
title='Test Media Post',
subtitle='Testing media folder creation',
content='This is a test post for media functionality',
difficulty=3,
media_folder='test_post_12345678_20250723',
published=True,
author_id=test_user.id
)
db.session.add(test_post)
db.session.commit()
# Check that media folder methods work
media_path = test_post.get_media_folder_path()
media_url = test_post.get_media_url_path()
print(f"✅ Post created with ID: {test_post.id}")
print(f"✅ Media folder: {test_post.media_folder}")
print(f"✅ Media path: {media_path}")
print(f"✅ Media URL: {media_url}")
# Test media config
config_path = MediaConfig.get_media_path(app, test_post.media_folder, 'images')
config_url = MediaConfig.get_media_url(test_post.media_folder, 'images', 'test.jpg')
print(f"✅ Config path: {config_path}")
print(f"✅ Config URL: {config_url}")
# Test file validation
valid_image = MediaConfig.is_allowed_file('test.jpg', 'images')
valid_gpx = MediaConfig.is_allowed_file('route.gpx', 'gpx')
invalid_file = MediaConfig.is_allowed_file('bad.exe', 'images')
print(f"✅ Valid image file: {valid_image}")
print(f"✅ Valid GPX file: {valid_gpx}")
print(f"✅ Invalid file rejected: {not invalid_file}")
return True
def test_image_processing():
"""Test image processing functionality"""
print("\n🖼️ Testing Image Processing...")
# Create a test image
img = Image.new('RGB', (800, 600), color='red')
img_buffer = BytesIO()
img.save(img_buffer, format='JPEG')
img_buffer.seek(0)
# Test image size limits
max_size = MediaConfig.IMAGE_MAX_SIZE
thumbnail_size = MediaConfig.THUMBNAIL_SIZE
print(f"✅ Max image size: {max_size}")
print(f"✅ Thumbnail size: {thumbnail_size}")
print(f"✅ Image quality: {MediaConfig.IMAGE_QUALITY}")
return True
def test_file_extensions():
"""Test file extension validation"""
print("\n📁 Testing File Extensions...")
# Test image extensions
image_exts = MediaConfig.UPLOAD_EXTENSIONS['images']
gpx_exts = MediaConfig.UPLOAD_EXTENSIONS['gpx']
print(f"✅ Allowed image extensions: {image_exts}")
print(f"✅ Allowed GPX extensions: {gpx_exts}")
# Test MIME types
valid_mimes = list(MediaConfig.ALLOWED_MIME_TYPES.keys())
print(f"✅ Allowed MIME types: {valid_mimes}")
return True
def main():
"""Run all tests"""
print("🧪 Media Management System Tests")
print("=" * 40)
try:
# Test media folder creation
print("\n📁 Testing Media Folder Creation...")
test_media_folder_creation()
# Test image processing
test_image_processing()
# Test file extensions
test_file_extensions()
print("\n✅ All tests passed!")
print("\n📋 Media System Summary:")
print(" - Media folders created per post")
print(" - Images automatically resized and compressed")
print(" - Thumbnails generated for all images")
print(" - GPX files validated and statistics extracted")
print(" - File type validation enforced")
print(" - Organized folder structure maintained")
except Exception as e:
print(f"❌ Test failed: {str(e)}")
return False
return True
if __name__ == '__main__':
success = main()
sys.exit(0 if success else 1)