Compare commits
30 Commits
7018ae13f0
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 16cc3fb0ba | |||
| dc7abe37c2 | |||
|
|
ee34215319 | ||
|
|
e5eef143fc | ||
|
|
5221cf3184 | ||
|
|
30bd4c62ad | ||
|
|
1661f5f588 | ||
|
|
d1e2b95678 | ||
|
|
343b7389e7 | ||
|
|
f2530a1c5b | ||
|
|
5897ed1cbc | ||
|
|
869a032051 | ||
|
|
3775462476 | ||
|
|
a64e206fc8 | ||
|
|
cee3711fd8 | ||
|
|
c0739f24a7 | ||
|
|
56c691c330 | ||
|
|
c9c3c80f4f | ||
|
|
377e379883 | ||
|
|
2a5b5ee468 | ||
|
|
5ddde4bd9b | ||
|
|
187254beca | ||
|
|
58e5d1b83d | ||
|
|
f9fcec83d5 | ||
|
|
d5c8ec1dc2 | ||
|
|
8691a6cd2d | ||
|
|
4fea7a6f49 | ||
|
|
73b90eafbc | ||
|
|
5a6dbc46eb | ||
|
|
1d12a882c1 |
@@ -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
@@ -118,4 +118,10 @@ Thumbs.db
|
||||
# Application specific
|
||||
uploads/
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite
|
||||
instance/
|
||||
venv/
|
||||
|
||||
# Media files and user content
|
||||
app/static/media/posts/
|
||||
data/
|
||||
33
Dockerfile
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
315
app/models.py
@@ -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}>'
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -0,0 +1,2 @@
|
||||
from flask_mail import Mail
|
||||
mail = Mail()
|
||||
@@ -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
|
||||
|
||||
12
app/routes/reset_password.py
Normal 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
@@ -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>
|
||||
98
app/static/map_iframe_single.html
Normal 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>
|
||||
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 14 KiB |
@@ -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>
|
||||
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 14 KiB |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
78
app/templates/admin/mail_settings.html
Normal 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 %}
|
||||
603
app/templates/admin/manage_chats.html
Normal 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"> </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 %}
|
||||
269
app/templates/admin/password_reset_email_template.html
Normal 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 %}
|
||||
242
app/templates/admin/password_reset_request_detail.html
Normal 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 %}
|
||||
230
app/templates/admin/password_reset_requests.html
Normal 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 %}
|
||||
324
app/templates/admin/password_reset_tokens.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
67
app/templates/auth/forgot_password.html
Normal 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 %}
|
||||
38
app/templates/auth/reset_password.html
Normal 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 %}
|
||||
182
app/templates/auth/reset_password_with_token.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
243
app/templates/chat/create_room.html
Normal 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 %}
|
||||
313
app/templates/chat/embed.html
Normal 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>
|
||||
253
app/templates/chat/index.html
Normal 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 %}
|
||||
205
app/templates/chat/post_discussions.html
Normal 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 %}
|
||||
172
app/templates/chat/post_specific_discussions.html
Normal 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 %}
|
||||
275
app/templates/chat/room.html
Normal 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 %}
|
||||
449
app/templates/chat/support.html
Normal 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 %}
|
||||
@@ -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
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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">×</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"]');
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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 %}
|
||||
@@ -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
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Utility functions package
|
||||
"""
|
||||
38
app/utils/clean_orphan_media.py
Normal 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.")
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
|
||||
@@ -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
@@ -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()
|
||||
39
migrations/add_category_to_chat_rooms.py
Normal 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
@@ -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()
|
||||
@@ -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')
|
||||
66
migrations/add_password_reset_system.py
Normal 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()
|
||||
|
Before Width: | Height: | Size: 602 KiB |
66
run.py
@@ -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."""
|
||||
|
||||
147
test_media.py
@@ -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)
|
||||