Compare commits

...

15 Commits

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

This ensures all user data persists across Docker container rebuilds and app updates.
2025-08-09 16:40:49 +03:00
ske087
343b7389e7 Fix GPX route processing to support GPX routes (not just tracks)
- Updated create_map_route_from_gpx function to parse GPX routes
- Added support for <rte> and <rtept> elements in addition to tracks
- This fixes the map not showing routes from GPX files that contain route data instead of track data
- Routes from applications like gpxplanner.app now work correctly
2025-08-09 15:49:26 +03:00
ske087
f2530a1c5b Update edit_post.html to match modern gradient design of new_post.html
- Applied consistent gradient background styling
- Updated form layout to match new_post design
- Modernized UI components with backdrop blur effects
- Enhanced JavaScript functionality for file uploads
- Improved preview modal styling
- Updated button styling to match site theme
2025-08-09 15:34:40 +03:00
ske087
5897ed1cbc Update Dockerfile and docker-compose for /opt/moto_site structure and reliable Docker deployment 2025-07-29 02:16:43 +03:00
ske087
869a032051 Final project state: all changes and cleanup complete 2025-07-27 02:28:02 +03:00
ske087
3775462476 Map: Use standard Leaflet green/red pin icons for GPX route start/end markers in map_iframe_single.html 2025-07-27 02:03:30 +03:00
ske087
a64e206fc8 Map: Use standard Leaflet green/red pin icons for GPX route start/end markers in map_iframe_single.html 2025-07-27 01:29:12 +03:00
ske087
cee3711fd8 Move config.py, create_admin.py, manage_media.py, manage_routes.py to app/utils/ for better organization 2025-07-27 00:29:26 +03:00
87 changed files with 6703 additions and 48803 deletions

8
.gitignore vendored
View File

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

View File

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

173
README.md
View File

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

View File

@@ -1,6 +1,6 @@
from flask import Flask
from app.extensions import db, migrate, login_manager, mail
from config import config
from app.utils.config import config
import os
def create_app(config_name=None):
@@ -87,6 +87,12 @@ 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)
@@ -94,21 +100,22 @@ def create_app(config_name=None):
os.makedirs(os.path.join(upload_dir, 'gpx'), exist_ok=True)
# --- Initial Admin Creation from .env ---
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.")
# Temporarily disabled for migration setup
# from app.models import User
# with app.app_context():
# admin_email = os.environ.get('ADMIN_EMAIL')
# admin_nickname = os.environ.get('ADMIN_NICKNAME')
# admin_password = os.environ.get('ADMIN_PASSWORD')
# if admin_email and admin_nickname and admin_password:
# if not User.query.filter_by(email=admin_email).first():
# user = User(nickname=admin_nickname, email=admin_email, is_admin=True, is_active=True)
# user.set_password(admin_password)
# db.session.add(user)
# db.session.commit()
# print(f"[INFO] Admin user {admin_nickname} <{admin_email}> created from .env.")
# else:
# print(f"[INFO] Admin with email {admin_email} already exists.")
# else:
# print("[INFO] ADMIN_EMAIL, ADMIN_NICKNAME, or ADMIN_PASSWORD not set in .env. Skipping admin creation.")
return app

View File

@@ -305,3 +305,201 @@ class SentEmail(db.Model):
def __repr__(self):
return f'<SentEmail to={self.recipient} subject={self.subject} status={self.status}>'
class ChatRoom(db.Model):
__tablename__ = 'chat_rooms'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text)
room_type = db.Column(db.String(50), default='general') # general, post_discussion, admin_support, password_reset
category = db.Column(db.String(50), default='general') # general, technical, maintenance, routes, events, safety, gear, social
is_private = db.Column(db.Boolean, default=False)
is_active = db.Column(db.Boolean, default=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
last_activity = db.Column(db.DateTime, default=datetime.utcnow)
# Foreign Keys
created_by_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
related_post_id = db.Column(db.Integer, db.ForeignKey('posts.id'), nullable=True) # For post discussions
# Relationships
created_by = db.relationship('User', backref='created_chat_rooms')
related_post = db.relationship('Post', backref='chat_rooms')
messages = db.relationship('ChatMessage', backref='room', lazy='dynamic', cascade='all, delete-orphan')
participants = db.relationship('ChatParticipant', backref='room', lazy='dynamic', cascade='all, delete-orphan')
def to_dict(self):
"""Convert to dictionary for API responses"""
return {
'id': self.id,
'name': self.name,
'description': self.description,
'room_type': self.room_type,
'is_private': self.is_private,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'last_activity': self.last_activity.isoformat() if self.last_activity else None,
'created_by': {
'id': self.created_by.id,
'nickname': self.created_by.nickname
} if self.created_by else None,
'related_post': {
'id': self.related_post.id,
'title': self.related_post.title
} if self.related_post else None,
'participant_count': self.participants.count(),
'message_count': self.messages.count()
}
def __repr__(self):
return f'<ChatRoom {self.name}>'
class ChatParticipant(db.Model):
__tablename__ = 'chat_participants'
id = db.Column(db.Integer, primary_key=True)
joined_at = db.Column(db.DateTime, default=datetime.utcnow)
last_seen = db.Column(db.DateTime, default=datetime.utcnow)
is_muted = db.Column(db.Boolean, default=False)
role = db.Column(db.String(50), default='member') # member, moderator, admin
# Foreign Keys
room_id = db.Column(db.Integer, db.ForeignKey('chat_rooms.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
# Relationships
user = db.relationship('User', backref='chat_participations')
# Unique constraint
__table_args__ = (db.UniqueConstraint('room_id', 'user_id', name='unique_room_participant'),)
def to_dict(self):
"""Convert to dictionary for API responses"""
return {
'id': self.id,
'user': {
'id': self.user.id,
'nickname': self.user.nickname,
'is_admin': self.user.is_admin
},
'role': self.role,
'joined_at': self.joined_at.isoformat() if self.joined_at else None,
'last_seen': self.last_seen.isoformat() if self.last_seen else None,
'is_muted': self.is_muted
}
def __repr__(self):
return f'<ChatParticipant {self.user.nickname} in {self.room.name}>'
class ChatMessage(db.Model):
__tablename__ = 'chat_messages'
id = db.Column(db.Integer, primary_key=True)
content = db.Column(db.Text, nullable=False)
message_type = db.Column(db.String(50), default='text') # text, system, file, image
is_edited = db.Column(db.Boolean, default=False)
is_deleted = db.Column(db.Boolean, default=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Foreign Keys
room_id = db.Column(db.Integer, db.ForeignKey('chat_rooms.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
reply_to_id = db.Column(db.Integer, db.ForeignKey('chat_messages.id'), nullable=True) # For threaded replies
# Relationships
user = db.relationship('User', backref='chat_messages')
reply_to = db.relationship('ChatMessage', remote_side=[id], backref='replies')
def to_dict(self):
"""Convert to dictionary for API responses"""
return {
'id': self.id,
'content': self.content,
'message_type': self.message_type,
'is_edited': self.is_edited,
'is_deleted': self.is_deleted,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'user': {
'id': self.user.id,
'nickname': self.user.nickname,
'is_admin': self.user.is_admin
},
'reply_to': {
'id': self.reply_to.id,
'content': self.reply_to.content[:100] + '...' if len(self.reply_to.content) > 100 else self.reply_to.content,
'user_nickname': self.reply_to.user.nickname
} if self.reply_to else None
}
def __repr__(self):
return f'<ChatMessage {self.id} by {self.user.nickname}>'
class PasswordResetRequest(db.Model):
"""Model for tracking password reset requests from chat system"""
__tablename__ = 'password_reset_requests'
id = db.Column(db.Integer, primary_key=True)
user_email = db.Column(db.String(120), nullable=False)
requester_message = db.Column(db.Text) # Original request message
status = db.Column(db.String(20), default='pending') # pending, token_generated, completed, expired
admin_notes = db.Column(db.Text) # Admin can add notes
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Foreign Keys
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True) # Nullable in case user not found
chat_message_id = db.Column(db.Integer, db.ForeignKey('chat_messages.id'), nullable=True)
# Relationships
user = db.relationship('User', backref='password_reset_requests')
chat_message = db.relationship('ChatMessage', backref='password_reset_request')
tokens = db.relationship('PasswordResetToken', backref='request', cascade='all, delete-orphan')
def __repr__(self):
return f'<PasswordResetRequest {self.id} for {self.user_email}>'
class PasswordResetToken(db.Model):
"""Model for one-time password reset tokens generated by admin"""
__tablename__ = 'password_reset_tokens'
id = db.Column(db.Integer, primary_key=True)
token = db.Column(db.String(255), unique=True, nullable=False)
is_used = db.Column(db.Boolean, default=False)
used_at = db.Column(db.DateTime, nullable=True)
expires_at = db.Column(db.DateTime, nullable=False)
created_at = db.Column(db.DateTime, default=datetime.utcnow)
user_ip = db.Column(db.String(45), nullable=True) # IP when token was used
user_agent = db.Column(db.Text, nullable=True) # User agent when token was used
# Foreign Keys
request_id = db.Column(db.Integer, db.ForeignKey('password_reset_requests.id'), nullable=False)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
created_by_admin_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
# Relationships
user = db.relationship('User', foreign_keys=[user_id], backref='reset_tokens')
created_by_admin = db.relationship('User', foreign_keys=[created_by_admin_id])
@property
def is_expired(self):
return datetime.utcnow() > self.expires_at
@property
def is_valid(self):
return not self.is_used and not self.is_expired
def mark_as_used(self, ip_address=None, user_agent=None):
self.is_used = True
self.used_at = datetime.utcnow()
self.user_ip = ip_address
self.user_agent = user_agent
db.session.commit()
def __repr__(self):
return f'<PasswordResetToken {self.token[:8]}... for {self.user.nickname}>'

View File

@@ -7,9 +7,10 @@ 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, MailSettings, SentEmail
from app.models import User, Post, PostImage, GPXFile, Comment, Like, PageView, MailSettings, SentEmail, PasswordResetRequest, PasswordResetToken, ChatRoom, ChatMessage
admin = Blueprint('admin', __name__, url_prefix='/admin')
@@ -151,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,
@@ -165,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
@@ -263,7 +286,15 @@ def delete_post(post_id):
current_app.logger.info(f'Found post to delete: ID={post.id}, Title="{title}"')
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()
@@ -498,3 +529,367 @@ def api_quick_stats():
'pending_posts': pending_count,
'today_views': today_views
})
# Password Reset Management Routes
@admin.route('/password-reset-requests')
@login_required
@admin_required
def password_reset_requests():
"""View all password reset requests"""
page = request.args.get('page', 1, type=int)
status = request.args.get('status', 'all')
query = PasswordResetRequest.query
if status != 'all':
query = query.filter_by(status=status)
requests = query.order_by(PasswordResetRequest.created_at.desc()).paginate(
page=page, per_page=20, error_out=False
)
return render_template('admin/password_reset_requests.html',
requests=requests, status=status)
@admin.route('/password-reset-requests/<int:request_id>')
@login_required
@admin_required
def password_reset_request_detail(request_id):
"""View individual password reset request details"""
reset_request = PasswordResetRequest.query.get_or_404(request_id)
# Get associated tokens
tokens = PasswordResetToken.query.filter_by(request_id=request_id).order_by(
PasswordResetToken.created_at.desc()
).all()
return render_template('admin/password_reset_request_detail.html',
request=reset_request, tokens=tokens)
@admin.route('/password-reset-requests/<int:request_id>/generate-token', methods=['POST'])
@login_required
@admin_required
def generate_password_reset_token(request_id):
"""Generate a new password reset token for a request"""
reset_request = PasswordResetRequest.query.get_or_404(request_id)
# Create token
token = str(uuid.uuid4())
expires_at = datetime.utcnow() + timedelta(hours=24) # 24 hour expiry
reset_token = PasswordResetToken(
token=token,
request_id=request_id,
user_id=reset_request.user.id,
created_by_admin_id=current_user.id,
expires_at=expires_at
)
db.session.add(reset_token)
reset_request.status = 'token_generated'
reset_request.updated_at = datetime.utcnow()
db.session.commit()
flash('Password reset token generated successfully!', 'success')
return redirect(url_for('admin.password_reset_token_template', token_id=reset_token.id))
@admin.route('/password-reset-tokens')
@login_required
@admin_required
def password_reset_tokens():
"""View all password reset tokens"""
page = request.args.get('page', 1, type=int)
status = request.args.get('status', 'all')
query = PasswordResetToken.query.join(User).order_by(PasswordResetToken.created_at.desc())
if status == 'active':
query = query.filter_by(is_used=False).filter(PasswordResetToken.expires_at > datetime.utcnow())
elif status == 'used':
query = query.filter_by(is_used=True)
elif status == 'expired':
query = query.filter(PasswordResetToken.expires_at <= datetime.utcnow(), PasswordResetToken.is_used == False)
tokens = query.paginate(page=page, per_page=20, error_out=False)
# Get counts for statistics
active_count = PasswordResetToken.query.filter_by(is_used=False).filter(
PasswordResetToken.expires_at > datetime.utcnow()
).count()
used_count = PasswordResetToken.query.filter_by(is_used=True).count()
expired_count = PasswordResetToken.query.filter(
PasswordResetToken.expires_at <= datetime.utcnow(),
PasswordResetToken.is_used == False
).count()
return render_template('admin/password_reset_tokens.html',
tokens=tokens, status=status,
active_count=active_count, used_count=used_count, expired_count=expired_count)
@admin.route('/manage-chats')
@login_required
@admin_required
def manage_chats():
"""Admin chat room management"""
page = request.args.get('page', 1, type=int)
category = request.args.get('category', '')
status = request.args.get('status', '')
search = request.args.get('search', '')
# Base query with message count
query = db.session.query(
ChatRoom,
func.count(ChatMessage.id).label('message_count'),
func.max(ChatMessage.created_at).label('last_activity')
).outerjoin(ChatMessage).group_by(ChatRoom.id)
# Apply filters
if category:
query = query.filter(ChatRoom.category == category)
if status == 'linked':
query = query.filter(ChatRoom.related_post_id.isnot(None))
elif status == 'unlinked':
query = query.filter(ChatRoom.related_post_id.is_(None))
elif status == 'active':
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
query = query.having(func.max(ChatMessage.created_at) >= thirty_days_ago)
elif status == 'inactive':
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
query = query.having(
db.or_(
func.max(ChatMessage.created_at) < thirty_days_ago,
func.max(ChatMessage.created_at).is_(None)
)
)
if search:
query = query.filter(
db.or_(
ChatRoom.name.contains(search),
ChatRoom.description.contains(search)
)
)
# Order by last activity
query = query.order_by(func.max(ChatMessage.created_at).desc().nullslast())
# Paginate
results = query.paginate(page=page, per_page=20, error_out=False)
# Process results to add message count and last activity to room objects
chat_rooms = []
for room, message_count, last_activity in results.items:
room.message_count = message_count
room.last_activity = last_activity
chat_rooms.append(room)
# Get statistics
total_rooms = ChatRoom.query.count()
linked_rooms = ChatRoom.query.filter(ChatRoom.related_post_id.isnot(None)).count()
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
active_today = db.session.query(ChatRoom.id).filter(
ChatRoom.messages.any(
func.date(ChatMessage.created_at) == datetime.utcnow().date()
)
).distinct().count()
total_messages = ChatMessage.query.count()
# Get available posts for linking
available_posts = Post.query.filter_by(published=True).order_by(Post.title).all()
# Create pagination object with processed rooms
class PaginationWrapper:
def __init__(self, original_pagination, items):
self.page = original_pagination.page
self.per_page = original_pagination.per_page
self.total = original_pagination.total
self.pages = original_pagination.pages
self.has_prev = original_pagination.has_prev
self.prev_num = original_pagination.prev_num
self.has_next = original_pagination.has_next
self.next_num = original_pagination.next_num
self.items = items
def iter_pages(self):
return range(1, self.pages + 1)
pagination = PaginationWrapper(results, chat_rooms)
return render_template('admin/manage_chats.html',
chat_rooms=chat_rooms,
pagination=pagination,
total_rooms=total_rooms,
linked_rooms=linked_rooms,
active_today=active_today,
total_messages=total_messages,
available_posts=available_posts)
@admin.route('/api/chat-rooms')
@login_required
@admin_required
def api_chat_rooms():
"""API endpoint for chat rooms (for AJAX calls)"""
exclude_id = request.args.get('exclude', type=int)
query = ChatRoom.query
if exclude_id:
query = query.filter(ChatRoom.id != exclude_id)
rooms = query.all()
# Get message counts
room_data = []
for room in rooms:
message_count = ChatMessage.query.filter_by(room_id=room.id).count()
room_data.append({
'id': room.id,
'name': room.name,
'description': room.description,
'category': room.category,
'message_count': message_count,
'created_at': room.created_at.isoformat() if room.created_at else None
})
return jsonify({'success': True, 'rooms': room_data})
@admin.route('/api/chat-rooms/<int:room_id>', methods=['PUT'])
@login_required
@admin_required
def api_update_chat_room(room_id):
"""Update chat room details"""
room = ChatRoom.query.get_or_404(room_id)
try:
data = request.get_json()
room.name = data.get('name', room.name)
room.description = data.get('description', room.description)
room.category = data.get('category', room.category)
# Handle post linking
related_post_id = data.get('related_post_id')
if related_post_id:
post = Post.query.get(related_post_id)
if post:
room.related_post_id = related_post_id
else:
return jsonify({'success': False, 'error': 'Post not found'})
else:
room.related_post_id = None
db.session.commit()
return jsonify({'success': True, 'message': 'Room updated successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)})
@admin.route('/api/chat-rooms/<int:room_id>/link-post', methods=['POST'])
@login_required
@admin_required
def api_link_chat_room_to_post(room_id):
"""Link chat room to a post"""
room = ChatRoom.query.get_or_404(room_id)
try:
data = request.get_json()
post_id = data.get('post_id')
if post_id:
post = Post.query.get_or_404(post_id)
room.related_post_id = post_id
else:
room.related_post_id = None
db.session.commit()
return jsonify({'success': True, 'message': 'Room linked to post successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)})
@admin.route('/api/chat-rooms/<int:source_room_id>/merge', methods=['POST'])
@login_required
@admin_required
def api_merge_chat_rooms(source_room_id):
"""Merge source room into target room"""
source_room = ChatRoom.query.get_or_404(source_room_id)
try:
data = request.get_json()
target_room_id = data.get('target_room_id')
target_room = ChatRoom.query.get_or_404(target_room_id)
# Move all messages from source to target room
messages = ChatMessage.query.filter_by(room_id=source_room_id).all()
for message in messages:
message.room_id = target_room_id
# Add system message about the merge
merge_message = ChatMessage(
room_id=target_room_id,
sender_id=current_user.id,
content=f"Room '{source_room.name}' has been merged into this room by admin {current_user.nickname}",
message_type='system',
is_system_message=True
)
db.session.add(merge_message)
# Delete the source room
db.session.delete(source_room)
db.session.commit()
return jsonify({'success': True, 'message': 'Rooms merged successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)})
@admin.route('/api/chat-rooms/<int:room_id>', methods=['DELETE'])
@login_required
@admin_required
def api_delete_chat_room(room_id):
"""Delete chat room and all its messages"""
room = ChatRoom.query.get_or_404(room_id)
try:
# Delete all messages first
ChatMessage.query.filter_by(room_id=room_id).delete()
# Delete the room
db.session.delete(room)
db.session.commit()
return jsonify({'success': True, 'message': 'Room deleted successfully'})
except Exception as e:
db.session.rollback()
return jsonify({'success': False, 'error': str(e)})
@admin.route('/password-reset-tokens/<int:token_id>/template')
@login_required
@admin_required
def password_reset_token_template(token_id):
"""Display email template for password reset token"""
token = PasswordResetToken.query.get_or_404(token_id)
# Generate the reset URL
reset_url = url_for('auth.reset_password_with_token', token=token.token, _external=True)
return render_template('admin/password_reset_email_template.html',
token=token, reset_url=reset_url)

View File

@@ -1,11 +1,12 @@
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.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
@@ -103,47 +104,107 @@ 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 = RequestResetForm()
if form.validate_on_submit():
user = User.query.filter_by(email=form.email.data).first()
if user:
token = generate_reset_token(user.email)
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\nTo reset your password, click the link below:\n{reset_url}\n\nIf you did not request this, please ignore this email."
# 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
)
try:
mail.send(msg)
except Exception as e:
flash(f"Failed to send reset email: {e}", "danger")
flash('If an account with that email exists, we\'ve sent password reset instructions.', 'info')
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:
message_content = f"A user with email '{user.email}' (nickname: {user.nickname}) needs their password to be changed. Please assist with password reset."
else:
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)
# Password reset route
@auth.route('/reset-password/<token>', methods=['GET', 'POST'])
def reset_password(token):
if current_user.is_authenticated:
return redirect(url_for('main.index'))
email = verify_reset_token(token)
if not email:
flash('Invalid or expired reset link.', 'danger')
return redirect(url_for('auth.forgot_password'))
user = User.query.filter_by(email=email).first()
if not user:
flash('Invalid or expired reset link.', 'danger')
return redirect(url_for('auth.forgot_password'))
form = ResetPasswordForm()
if form.validate_on_submit():
user.set_password(form.password.data)
@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('Your password has been reset. You can now log in.', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/reset_password.html', form=form)
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"""
@@ -154,3 +215,59 @@ def is_valid_password(password):
if not re.search(r'\d', password):
return False
return True
class ResetPasswordWithTokenForm(FlaskForm):
password = PasswordField('New Password', validators=[DataRequired(), Length(min=8)])
password2 = PasswordField('Confirm New Password', validators=[DataRequired(), EqualTo('password')])
submit = SubmitField('Reset Password')
@auth.route('/reset-password/<token>', methods=['GET', 'POST'])
def reset_password_with_token(token):
"""Reset password using admin-generated token"""
# Find the token in database
reset_token = PasswordResetToken.query.filter_by(token=token).first()
if not reset_token:
flash('Invalid or expired reset link.', 'error')
return redirect(url_for('auth.login'))
# Check if token is expired
if reset_token.is_expired:
flash('This reset link has expired. Please request a new one.', 'error')
return redirect(url_for('auth.login'))
# Check if token is already used
if reset_token.is_used:
flash('This reset link has already been used.', 'error')
return redirect(url_for('auth.login'))
form = ResetPasswordWithTokenForm()
if form.validate_on_submit():
user = reset_token.user
# Validate password strength
if not is_valid_password(form.password.data):
flash('Password must be at least 8 characters long and contain both letters and numbers.', 'error')
return render_template('auth/reset_password_with_token.html', form=form, token=token)
# Update password
user.set_password(form.password.data)
# Mark token as used
reset_token.used_at = datetime.utcnow()
reset_token.user_ip = request.environ.get('REMOTE_ADDR')
# Update request status
if reset_token.request:
reset_token.request.status = 'completed'
reset_token.request.updated_at = datetime.utcnow()
db.session.commit()
flash('Your password has been reset successfully! You can now log in with your new password.', 'success')
return redirect(url_for('auth.login'))
return render_template('auth/reset_password_with_token.html', form=form, token=token, user=reset_token.user)

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

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

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

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

View File

@@ -143,8 +143,6 @@
<!-- Map Controls -->
<div class="map-controls">
<button id="fit-routes" class="map-control-btn" title="Fit all routes">🎯</button>
<button id="toggle-routes" class="map-control-btn active" title="Toggle routes">🛣️</button>
<button id="refresh-map" class="map-control-btn" title="Refresh map">🔄</button>
</div>
@@ -162,10 +160,22 @@
// Create map
map = L.map('map', {
zoomControl: true,
scrollWheelZoom: 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', {
@@ -226,7 +236,7 @@
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,
@@ -234,7 +244,7 @@
opacity: 0.8,
smoothFactor: 1
});
// Create popup content
const popupContent = `
<div class="route-popup">
@@ -260,15 +270,43 @@
</a>
</div>
`;
polyline.bindPopup(popupContent);
// Add to route layer
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)`);
}
});
@@ -298,27 +336,7 @@
}
function setupControls() {
// Fit routes button
document.getElementById('fit-routes').addEventListener('click', () => {
if (routeLayer.getLayers().length > 0) {
const group = new L.FeatureGroup();
routeLayer.eachLayer(layer => group.addLayer(layer));
map.fitBounds(group.getBounds(), { padding: [20, 20] });
}
});
// Toggle routes button
document.getElementById('toggle-routes').addEventListener('click', (e) => {
if (routesVisible) {
map.removeLayer(routeLayer);
e.target.classList.remove('active');
routesVisible = false;
} else {
map.addLayer(routeLayer);
e.target.classList.add('active');
routesVisible = true;
}
});
// Only refresh map button remains
// Refresh map button
document.getElementById('refresh-map').addEventListener('click', () => {

View File

@@ -58,10 +58,28 @@ if (routeId) {
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
L.marker(latlngs[0], { icon: L.divIcon({ html: '<div style="background:#22c55e;border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:#fff;"><i class=\'fas fa-play\'></i></div>', className: 'custom-div-icon', iconSize: [24,24], iconAnchor: [12,12] }) }).addTo(map);
// End marker
L.marker(latlngs[latlngs.length-1], { icon: L.divIcon({ html: '<div style="background:#ef4444;border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:#fff;"><i class=\'fas fa-flag-checkered\'></i></div>', className: 'custom-div-icon', iconSize: [24,24], iconAnchor: [12,12] }) }).addTo(map);
// 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();

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 311 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 425 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 442 KiB

View File

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

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 491 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 499 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 526 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 97 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 155 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 126 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -21,6 +21,9 @@
</div>
<div class="hidden md:flex items-center space-x-8">
<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 %}
{% if not current_user.is_admin %}
@@ -59,6 +62,9 @@
<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 %}
{% 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">
@@ -176,5 +182,7 @@
});
});
</script>
{% block scripts %}{% endblock %}
</body>
</html>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -642,6 +642,9 @@
</div>
</div>
<!-- Chat Discussion Widget -->
{% include 'chat/embed.html' %}
{% endblock %}
{% block scripts %}
@@ -882,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())

View File

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

View File

@@ -92,6 +92,24 @@ def extract_gpx_statistics(file_path: str) -> Optional[Dict]:
'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,
@@ -325,7 +343,26 @@ def create_map_route_from_gpx(gpx_file_id: int) -> bool:
total_distance += distance
prev_point = point
# If no track points, try waypoints
# 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]

View File

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

44
fix_gpx_statistics.py Normal file
View File

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

View File

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

160
migrations/add_chat_system.py Executable file
View File

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

View File

@@ -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()

View File