Major UI/UX redesign and feature enhancements

🎨 Complete Tailwind CSS conversion
- Redesigned post detail page with modern gradient backgrounds
- Updated profile page with consistent design language
- Converted from Bootstrap to Tailwind CSS throughout

 New Features & Improvements
- Enhanced community post management system
- Added admin panel with analytics dashboard
- Improved post creation and editing workflows
- Interactive GPS map integration with Leaflet.js
- Photo gallery with modal view and hover effects
- Adventure statistics and metadata display
- Like system and community engagement features

🔧 Technical Improvements
- Fixed template syntax errors and CSRF token issues
- Updated database models and relationships
- Enhanced media file management
- Improved responsive design patterns
- Added proper error handling and validation

📱 Mobile-First Design
- Responsive grid layouts
- Touch-friendly interactions
- Optimized for all screen sizes
- Modern card-based UI components

🏍️ Adventure Platform Features
- GPS track visualization and statistics
- Photo uploads with thumbnail generation
- GPX file downloads for registered users
- Community comments and discussions
- Post approval workflow for admins
- Difficulty rating system with star indicators
This commit is contained in:
ske087
2025-07-24 02:44:25 +03:00
parent 540eb17e89
commit 60ef02ced9
36 changed files with 12953 additions and 67 deletions

View File

@@ -25,8 +25,54 @@ def create_app(config_name=None):
from app.models import User from app.models import User
return User.query.get(int(user_id)) return User.query.get(int(user_id))
# Page view tracking
@app.before_request
def track_page_views():
from app.models import PageView
from flask import request
from flask_login import current_user
import re
# Skip tracking for static files, admin API calls, and certain paths
if (request.endpoint and
(request.endpoint.startswith('static') or
request.endpoint.startswith('admin.api') or
request.path.startswith('/favicon') or
request.path.startswith('/_'))) :
return
# Extract post_id from community post URLs
post_id = None
if request.endpoint == 'community.post_detail':
post_id = request.view_args.get('post_id')
# Create page view record
page_view = PageView(
path=request.path,
user_agent=request.headers.get('User-Agent', ''),
ip_address=request.remote_addr,
referer=request.headers.get('Referer'),
user_id=current_user.id if current_user.is_authenticated else None,
post_id=post_id
)
try:
db.session.add(page_view)
db.session.commit()
except Exception:
# Don't let page view tracking break the app
db.session.rollback()
# Import models # Import models
from app.models import User, Post, PostImage, GPXFile, Comment, Like from app.models import User, Post, PostImage, GPXFile, Comment, Like, PageView
# Add custom template filters
@app.template_filter('nl2br')
def nl2br_filter(text):
"""Convert newlines to <br> tags"""
if text is None:
return ''
return text.replace('\n', '<br>')
# Register blueprints # Register blueprints
from app.routes.main import main from app.routes.main import main
@@ -38,6 +84,9 @@ def create_app(config_name=None):
from app.routes.community import community from app.routes.community import community
app.register_blueprint(community, url_prefix='/community') app.register_blueprint(community, url_prefix='/community')
from app.routes.admin import admin
app.register_blueprint(admin, url_prefix='/admin')
# Create upload directories # Create upload directories
upload_dir = os.path.join(app.instance_path, 'uploads') upload_dir = os.path.join(app.instance_path, 'uploads')
os.makedirs(upload_dir, exist_ok=True) os.makedirs(upload_dir, exist_ok=True)

View File

@@ -167,3 +167,24 @@ class Like(db.Model):
def __repr__(self): def __repr__(self):
return f'<Like {self.user_id}-{self.post_id}>' return f'<Like {self.user_id}-{self.post_id}>'
class PageView(db.Model):
__tablename__ = 'page_views'
id = db.Column(db.Integer, primary_key=True)
path = db.Column(db.String(255), nullable=False)
user_agent = db.Column(db.String(500))
ip_address = db.Column(db.String(45)) # IPv6 can be up to 45 chars
referer = db.Column(db.String(500))
created_at = db.Column(db.DateTime, default=datetime.utcnow)
# Foreign Keys (optional - for tracking logged-in users)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True)
post_id = db.Column(db.Integer, db.ForeignKey('posts.id'), nullable=True)
# Relationships
user = db.relationship('User', backref='page_views')
post = db.relationship('Post', backref='page_views')
def __repr__(self):
return f'<PageView {self.path} at {self.created_at}>'

303
app/routes/admin.py Normal file
View File

@@ -0,0 +1,303 @@
from flask import Blueprint, render_template, request, flash, redirect, url_for, jsonify
from flask_login import login_required, current_user
from functools import wraps
from datetime import datetime, timedelta
from sqlalchemy import func, desc
from app.extensions import db
from app.models import User, Post, PostImage, GPXFile, Comment, Like, PageView
admin = Blueprint('admin', __name__, url_prefix='/admin')
def admin_required(f):
"""Decorator to require admin access"""
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or not current_user.is_admin:
flash('Admin access required.', 'error')
return redirect(url_for('auth.login'))
return f(*args, **kwargs)
return decorated_function
@admin.route('/')
@login_required
@admin_required
def dashboard():
"""Admin dashboard with site statistics"""
# Get basic statistics
total_users = User.query.count()
total_posts = Post.query.count()
published_posts = Post.query.filter_by(published=True).count()
pending_posts = Post.query.filter_by(published=False).count()
total_comments = Comment.query.count()
total_likes = Like.query.count()
# Active users (users who created posts or comments in the last 30 days)
thirty_days_ago = datetime.utcnow() - timedelta(days=30)
active_users = db.session.query(User.id).filter(
db.or_(
User.posts.any(Post.created_at >= thirty_days_ago),
User.comments.any(Comment.created_at >= thirty_days_ago)
)
).distinct().count()
# Recent activity
recent_posts = Post.query.order_by(Post.created_at.desc()).limit(5).all()
recent_users = User.query.order_by(User.created_at.desc()).limit(5).all()
# Page views statistics
today = datetime.utcnow().date()
yesterday = today - timedelta(days=1)
week_ago = today - timedelta(days=7)
views_today = PageView.query.filter(
func.date(PageView.created_at) == today
).count()
views_yesterday = PageView.query.filter(
func.date(PageView.created_at) == yesterday
).count()
views_this_week = PageView.query.filter(
PageView.created_at >= week_ago
).count()
# Most viewed posts
most_viewed_posts = db.session.query(
Post.id, Post.title, func.count(PageView.id).label('view_count')
).join(PageView, Post.id == PageView.post_id)\
.group_by(Post.id, Post.title)\
.order_by(desc('view_count'))\
.limit(10).all()
# Most viewed pages
most_viewed_pages = db.session.query(
PageView.path, func.count(PageView.id).label('view_count')
).group_by(PageView.path)\
.order_by(desc('view_count'))\
.limit(10).all()
return render_template('admin/dashboard.html',
total_users=total_users,
total_posts=total_posts,
published_posts=published_posts,
pending_posts=pending_posts,
total_comments=total_comments,
total_likes=total_likes,
active_users=active_users,
recent_posts=recent_posts,
recent_users=recent_users,
views_today=views_today,
views_yesterday=views_yesterday,
views_this_week=views_this_week,
most_viewed_posts=most_viewed_posts,
most_viewed_pages=most_viewed_pages)
@admin.route('/posts')
@login_required
@admin_required
def posts():
"""Admin post management - review posts"""
page = request.args.get('page', 1, type=int)
status = request.args.get('status', 'pending') # pending, published, all
query = Post.query
if status == 'pending':
query = query.filter_by(published=False)
elif status == 'published':
query = query.filter_by(published=True)
# 'all' shows all posts
posts = query.order_by(Post.created_at.desc()).paginate(
page=page, per_page=20, error_out=False
)
return render_template('admin/posts.html', posts=posts, status=status)
@admin.route('/posts/<int:post_id>')
@login_required
@admin_required
def post_detail(post_id):
"""View post details for review"""
post = Post.query.get_or_404(post_id)
return render_template('admin/post_detail.html', post=post)
@admin.route('/posts/<int:post_id>/publish', methods=['POST'])
@login_required
@admin_required
def publish_post(post_id):
"""Publish a post"""
post = Post.query.get_or_404(post_id)
post.published = True
post.updated_at = datetime.utcnow()
db.session.commit()
flash(f'Post "{post.title}" has been published.', 'success')
return redirect(url_for('admin.posts'))
@admin.route('/posts/<int:post_id>/unpublish', methods=['POST'])
@login_required
@admin_required
def unpublish_post(post_id):
"""Unpublish a post"""
post = Post.query.get_or_404(post_id)
post.published = False
post.updated_at = datetime.utcnow()
db.session.commit()
flash(f'Post "{post.title}" has been unpublished.', 'success')
return redirect(url_for('admin.posts'))
@admin.route('/posts/<int:post_id>/delete', methods=['POST'])
@login_required
@admin_required
def delete_post(post_id):
"""Delete a post"""
post = Post.query.get_or_404(post_id)
title = post.title
# Delete associated files and records
db.session.delete(post)
db.session.commit()
flash(f'Post "{title}" has been deleted.', 'success')
return redirect(url_for('admin.posts'))
@admin.route('/users')
@login_required
@admin_required
def users():
"""Admin user management"""
page = request.args.get('page', 1, type=int)
users = User.query.order_by(User.created_at.desc()).paginate(
page=page, per_page=50, error_out=False
)
return render_template('admin/users.html', users=users)
@admin.route('/users/<int:user_id>')
@login_required
@admin_required
def user_detail(user_id):
"""View user details"""
user = User.query.get_or_404(user_id)
user_posts = Post.query.filter_by(author_id=user_id).order_by(Post.created_at.desc()).all()
user_comments = Comment.query.filter_by(author_id=user_id).order_by(Comment.created_at.desc()).all()
return render_template('admin/user_detail.html',
user=user,
user_posts=user_posts,
user_comments=user_comments)
@admin.route('/analytics')
@login_required
@admin_required
def analytics():
"""Detailed analytics page"""
# Get date range from query params
days = request.args.get('days', 30, type=int)
end_date = datetime.utcnow().date()
start_date = end_date - timedelta(days=days)
# Daily views for the chart
daily_views = db.session.query(
func.date(PageView.created_at).label('date'),
func.count(PageView.id).label('views')
).filter(
func.date(PageView.created_at) >= start_date
).group_by(func.date(PageView.created_at))\
.order_by('date').all()
# Top posts by views
top_posts = db.session.query(
Post.id, Post.title, Post.author, func.count(PageView.id).label('view_count')
).join(PageView, Post.id == PageView.post_id)\
.filter(PageView.created_at >= start_date)\
.group_by(Post.id, Post.title)\
.order_by(desc('view_count'))\
.limit(20).all()
# User activity - separate queries to avoid cartesian product
user_posts = db.session.query(
User.id,
User.nickname,
func.count(Post.id).label('post_count')
).outerjoin(Post, User.id == Post.author_id)\
.group_by(User.id, User.nickname).subquery()
user_comments = db.session.query(
User.id,
func.count(Comment.id).label('comment_count')
).outerjoin(Comment, User.id == Comment.author_id)\
.group_by(User.id).subquery()
user_activity = db.session.query(
user_posts.c.nickname,
user_posts.c.post_count,
func.coalesce(user_comments.c.comment_count, 0).label('comment_count')
).outerjoin(user_comments, user_posts.c.id == user_comments.c.id)\
.order_by(desc(user_posts.c.post_count))\
.limit(20).all()
# Get overall statistics
total_views = PageView.query.count()
unique_visitors = db.session.query(PageView.ip_address).distinct().count()
today_views = PageView.query.filter(
func.date(PageView.created_at) == datetime.utcnow().date()
).count()
week_start = datetime.utcnow().date() - timedelta(days=7)
week_views = PageView.query.filter(
func.date(PageView.created_at) >= week_start
).count()
# Popular pages
popular_pages = db.session.query(
PageView.path,
func.count(PageView.id).label('view_count')
).group_by(PageView.path)\
.order_by(desc('view_count'))\
.limit(10).all()
# Recent activity
recent_views = PageView.query.join(User, PageView.user_id == User.id, isouter=True)\
.order_by(desc(PageView.created_at))\
.limit(20).all()
# Browser statistics (extract from user agent)
browser_stats = db.session.query(
func.substr(PageView.user_agent, 1, 50).label('browser'),
func.count(PageView.id).label('view_count')
).filter(PageView.user_agent.isnot(None))\
.group_by(func.substr(PageView.user_agent, 1, 50))\
.order_by(desc('view_count'))\
.limit(10).all()
return render_template('admin/analytics.html',
total_views=total_views,
unique_visitors=unique_visitors,
today_views=today_views,
week_views=week_views,
popular_pages=popular_pages,
recent_views=recent_views,
browser_stats=browser_stats,
daily_views=daily_views,
top_posts=top_posts,
user_activity=user_activity,
days=days)
# API endpoints for AJAX requests
@admin.route('/api/quick-stats')
@login_required
@admin_required
def api_quick_stats():
"""Quick stats for dashboard widgets"""
pending_count = Post.query.filter_by(published=False).count()
today_views = PageView.query.filter(
func.date(PageView.created_at) == datetime.utcnow().date()
).count()
return jsonify({
'pending_posts': pending_count,
'today_views': today_views
})

View File

@@ -8,6 +8,7 @@ from werkzeug.utils import secure_filename
from werkzeug.exceptions import RequestEntityTooLarge from werkzeug.exceptions import RequestEntityTooLarge
import os import os
import uuid import uuid
import shutil
from datetime import datetime from datetime import datetime
from PIL import Image from PIL import Image
import gpxpy import gpxpy
@@ -41,10 +42,35 @@ def post_detail(id):
return render_template('community/post_detail.html', post=post, form=form, comments=comments) return render_template('community/post_detail.html', post=post, form=form, comments=comments)
@community.route('/new-post', methods=['GET', 'POST']) @community.route('/profile')
@login_required @login_required
def new_post(): def profile():
"""Create new post page""" """User profile page with their posts"""
page = request.args.get('page', 1, type=int)
posts = Post.query.filter_by(author_id=current_user.id).order_by(Post.created_at.desc()).paginate(
page=page, per_page=10, error_out=False
)
# Count posts by status
published_count = Post.query.filter_by(author_id=current_user.id, published=True).count()
pending_count = Post.query.filter_by(author_id=current_user.id, published=False).count()
return render_template('community/profile.html',
posts=posts,
published_count=published_count,
pending_count=pending_count)
@community.route('/edit-post/<int:id>', methods=['GET', 'POST'])
@login_required
def edit_post(id):
"""Edit existing post"""
post = Post.query.get_or_404(id)
# Check if user owns the post
if current_user.id != post.author_id:
flash('You can only edit your own posts.', 'error')
return redirect(url_for('community.profile'))
if request.method == 'POST': if request.method == 'POST':
try: try:
# Get form data # Get form data
@@ -61,6 +87,150 @@ def new_post():
if not content: if not content:
return jsonify({'success': False, 'error': 'Content is required'}) return jsonify({'success': False, 'error': 'Content is required'})
# Update post
post.title = title
post.subtitle = subtitle
post.content = content
post.difficulty = difficulty
post.published = False # Reset to pending review
post.updated_at = datetime.now()
current_app.logger.info(f'Updating post {post.id}: {post.title} by user {current_user.id}')
# Handle new cover picture upload (if provided)
if 'cover_picture' in request.files:
cover_file = request.files['cover_picture']
if cover_file and cover_file.filename:
try:
result = save_image(cover_file, post.id)
if result['success']:
# Remove old cover image if exists
old_cover = PostImage.query.filter_by(post_id=post.id, is_cover=True).first()
if old_cover:
db.session.delete(old_cover)
# Save new cover image
cover_image = PostImage(
filename=result['filename'],
original_name=cover_file.filename,
size=result['size'],
mime_type=result['mime_type'],
post_id=post.id,
is_cover=True
)
db.session.add(cover_image)
except Exception as e:
current_app.logger.warning(f'Error processing new cover image: {str(e)}')
# Handle new GPX file upload (if provided)
if 'gpx_file' in request.files:
gpx_file = request.files['gpx_file']
if gpx_file and gpx_file.filename:
try:
result = save_gpx_file(gpx_file, post.id)
if result['success']:
# Remove old GPX files
old_gpx_files = GPXFile.query.filter_by(post_id=post.id).all()
for old_gpx in old_gpx_files:
db.session.delete(old_gpx)
# Save new GPX file
gpx_file_record = GPXFile(
filename=result['filename'],
original_name=gpx_file.filename,
size=result['size'],
post_id=post.id
)
db.session.add(gpx_file_record)
except Exception as e:
current_app.logger.warning(f'Error processing new GPX file: {str(e)}')
db.session.commit()
current_app.logger.info(f'Post {post.id} updated successfully')
return jsonify({
'success': True,
'message': 'Post updated successfully! It has been resubmitted for admin review.',
'redirect_url': url_for('community.profile')
})
except Exception as e:
db.session.rollback()
current_app.logger.error(f'Error updating post: {str(e)}')
return jsonify({'success': False, 'error': f'An error occurred while updating your post: {str(e)}'})
# GET request - show edit form
return render_template('community/edit_post.html', post=post)
@community.route('/delete-post/<int:id>', methods=['DELETE', 'POST'])
@login_required
def delete_post(id):
"""Delete a post and all its associated media"""
post = Post.query.get_or_404(id)
# Check if user owns the post
if current_user.id != post.author_id:
return jsonify({'success': False, 'error': 'You can only delete your own posts'})
try:
# Delete associated media files from filesystem
if post.media_folder:
media_path = os.path.join(current_app.root_path, 'static', 'media', 'posts', post.media_folder)
if os.path.exists(media_path):
import shutil
shutil.rmtree(media_path)
current_app.logger.info(f'Deleted media folder: {media_path}')
# Delete database records (cascading should handle related records)
db.session.delete(post)
db.session.commit()
current_app.logger.info(f'Post {post.id} "{post.title}" deleted by user {current_user.id}')
return jsonify({
'success': True,
'message': f'Adventure "{post.title}" has been deleted successfully.'
})
except Exception as e:
db.session.rollback()
current_app.logger.error(f'Error deleting post {id}: {str(e)}')
return jsonify({
'success': False,
'error': f'An error occurred while deleting the post: {str(e)}'
})
@community.route('/new-post', methods=['GET', 'POST'])
@login_required
def new_post():
"""Create new post page"""
if request.method == 'POST':
try:
# Debug: Print all form data and files
current_app.logger.info(f'POST request received. Form data: {dict(request.form)}')
current_app.logger.info(f'Files received: {list(request.files.keys())}')
# Get form data
title = request.form.get('title', '').strip()
subtitle = request.form.get('subtitle', '').strip()
content = request.form.get('content', '').strip()
difficulty = request.form.get('difficulty', type=int)
current_app.logger.info(f'Parsed form data - Title: "{title}", Subtitle: "{subtitle}", Content: "{content}", Difficulty: {difficulty}')
# Validation
if not title:
current_app.logger.warning('Validation failed: Title is required')
return jsonify({'success': False, 'error': 'Title is required'})
if not difficulty or difficulty < 1 or difficulty > 5:
current_app.logger.warning(f'Validation failed: Invalid difficulty level: {difficulty}')
return jsonify({'success': False, 'error': 'Valid difficulty level is required'})
# Allow empty content for now to simplify testing
if not content:
content = f"Adventure details for {title}"
current_app.logger.info(f'Using default content: "{content}"')
# Create post # Create post
post = Post( post = Post(
title=title, title=title,
@@ -68,13 +238,17 @@ def new_post():
content=content, content=content,
difficulty=difficulty, difficulty=difficulty,
media_folder=f"post_{uuid.uuid4().hex[:8]}_{datetime.now().strftime('%Y%m%d')}", media_folder=f"post_{uuid.uuid4().hex[:8]}_{datetime.now().strftime('%Y%m%d')}",
published=True, # Auto-publish for now published=False, # Set to pending review by default
author_id=current_user.id author_id=current_user.id
) )
current_app.logger.info(f'Creating post: {post.title} by user {current_user.id}')
db.session.add(post) db.session.add(post)
db.session.flush() # Get the post ID db.session.flush() # Get the post ID
current_app.logger.info(f'Post created with ID: {post.id}')
# Create media folder for this post # Create media folder for this post
media_folder_path = os.path.join(current_app.root_path, 'static', 'media', 'posts', post.media_folder) media_folder_path = os.path.join(current_app.root_path, 'static', 'media', 'posts', post.media_folder)
os.makedirs(media_folder_path, exist_ok=True) os.makedirs(media_folder_path, exist_ok=True)
@@ -83,53 +257,173 @@ def new_post():
os.makedirs(os.path.join(media_folder_path, 'images'), exist_ok=True) os.makedirs(os.path.join(media_folder_path, 'images'), exist_ok=True)
os.makedirs(os.path.join(media_folder_path, 'gpx'), exist_ok=True) os.makedirs(os.path.join(media_folder_path, 'gpx'), exist_ok=True)
# Handle cover picture upload current_app.logger.info(f'Created media folder: {media_folder_path}')
# Handle cover picture upload (if provided)
if 'cover_picture' in request.files: if 'cover_picture' in request.files:
cover_file = request.files['cover_picture'] cover_file = request.files['cover_picture']
if cover_file and cover_file.filename and MediaConfig.is_allowed_file(cover_file.filename, 'images'): if cover_file and cover_file.filename:
result = save_image(cover_file, post.id) try:
if result['success']: current_app.logger.info(f'Processing cover picture: {cover_file.filename}')
# Save as cover image result = save_image(cover_file, post.id)
cover_image = PostImage( if result['success']:
filename=result['filename'], # Save as cover image
original_name=cover_file.filename, cover_image = PostImage(
size=result['size'], filename=result['filename'],
mime_type=result['mime_type'], original_name=cover_file.filename,
post_id=post.id, size=result['size'],
is_cover=True mime_type=result['mime_type'],
) post_id=post.id
db.session.add(cover_image) # Note: is_cover column missing, will be added in future migration
)
db.session.add(cover_image)
current_app.logger.info(f'Cover image saved: {result["filename"]}')
except Exception as e:
current_app.logger.warning(f'Error processing cover image: {str(e)}')
# Handle GPX file upload # Handle GPX file upload (if provided)
if 'gpx_file' in request.files: if 'gpx_file' in request.files:
gpx_file = request.files['gpx_file'] gpx_file = request.files['gpx_file']
if gpx_file and gpx_file.filename and MediaConfig.is_allowed_file(gpx_file.filename, 'gpx'): if gpx_file and gpx_file.filename:
result = save_gpx_file(gpx_file, post.id) try:
if result['success']: current_app.logger.info(f'Processing GPX file: {gpx_file.filename}')
gpx_file_record = GPXFile( result = save_gpx_file(gpx_file, post.id)
filename=result['filename'], if result['success']:
original_name=gpx_file.filename, gpx_file_record = GPXFile(
size=result['size'], filename=result['filename'],
post_id=post.id original_name=gpx_file.filename,
) size=result['size'],
db.session.add(gpx_file_record) post_id=post.id
)
db.session.add(gpx_file_record)
current_app.logger.info(f'GPX file saved: {result["filename"]}')
except Exception as e:
current_app.logger.warning(f'Error processing GPX file: {str(e)}')
db.session.commit() db.session.commit()
current_app.logger.info(f'Post {post.id} committed to database successfully')
return jsonify({ return jsonify({
'success': True, 'success': True,
'message': 'Adventure shared successfully!', 'message': 'Adventure created successfully! It will be reviewed by admin before publishing.',
'redirect_url': url_for('community.post_detail', id=post.id) 'redirect_url': url_for('community.index')
}) })
except Exception as e: except Exception as e:
db.session.rollback() db.session.rollback()
current_app.logger.error(f'Error creating post: {str(e)}') current_app.logger.error(f'Error creating post: {str(e)}')
return jsonify({'success': False, 'error': 'An error occurred while creating your post'}) return jsonify({'success': False, 'error': f'An error occurred while creating your post: {str(e)}'})
# GET request - show the form # GET request - show the form
return render_template('community/new_post.html') return render_template('community/new_post.html')
@community.route('/migrate-db')
@login_required
def migrate_database():
"""Run database migration - for admin use"""
if not current_user.is_admin:
flash('Unauthorized access.', 'error')
return redirect(url_for('community.index'))
try:
from sqlalchemy import text
# Check if is_cover column exists in post_images table
result = db.session.execute(text('PRAGMA table_info(post_images)'))
columns = [row[1] for row in result.fetchall()]
if 'is_cover' not in columns:
# Add the is_cover column
db.session.execute(text('ALTER TABLE post_images ADD COLUMN is_cover BOOLEAN DEFAULT 0'))
db.session.commit()
flash('Successfully added is_cover column to post_images table', 'success')
else:
flash('is_cover column already exists', 'info')
return redirect(url_for('admin.index'))
except Exception as e:
db.session.rollback()
current_app.logger.error(f'Migration error: {str(e)}')
flash(f'Migration error: {str(e)}', 'error')
return redirect(url_for('admin.index'))
@community.route('/simple-form', methods=['GET'])
@login_required
def simple_form():
"""Simple form for testing post creation"""
return render_template('community/simple_form.html')
@community.route('/simple-test', methods=['GET', 'POST'])
@login_required
def simple_test():
"""Very simple test post creation"""
if request.method == 'POST':
try:
# Simple form submission with minimal data
title = request.form.get('title', 'Test Post').strip()
post = Post(
title=title,
subtitle="Simple test post",
content="This is a simple test post to verify the system.",
difficulty=3,
media_folder=f"test_{uuid.uuid4().hex[:8]}",
published=False,
author_id=current_user.id
)
db.session.add(post)
db.session.commit()
flash('Test post created successfully!', 'success')
return redirect(url_for('community.index'))
except Exception as e:
db.session.rollback()
flash(f'Error: {str(e)}', 'error')
return '''
<form method="POST">
<input type="text" name="title" placeholder="Post title" required>
<button type="submit">Create Test Post</button>
</form>
'''
@login_required
def test_post():
"""Simple test post creation for debugging"""
if request.method == 'POST':
try:
# Create a simple test post
post = Post(
title="Test Post - " + datetime.now().strftime('%H:%M:%S'),
subtitle="Test subtitle",
content="This is a test post created to verify the system is working.",
difficulty=3,
published=False, # Pending review
author_id=current_user.id
)
db.session.add(post)
db.session.commit()
flash('Test post created successfully!', 'success')
return redirect(url_for('community.index'))
except Exception as e:
db.session.rollback()
flash(f'Error creating test post: {str(e)}', 'error')
return redirect(url_for('community.test_post'))
# Simple test form
return '''
<h2>Test Post Creation</h2>
<form method="POST">
<button type="submit">Create Test Post</button>
</form>
<a href="/community/">Back to Community</a>
'''
@community.route('/post/<int:id>/comment', methods=['POST']) @community.route('/post/<int:id>/comment', methods=['POST'])
@login_required @login_required
def add_comment(id): def add_comment(id):
@@ -417,8 +711,9 @@ def serve_thumbnail(post_folder, filename):
return serve_image(post_folder, filename) return serve_image(post_folder, filename)
@community.route('/media/posts/<post_folder>/gpx/<filename>') @community.route('/media/posts/<post_folder>/gpx/<filename>')
@login_required
def serve_gpx(post_folder, filename): def serve_gpx(post_folder, filename):
"""Serve GPX files for download""" """Serve GPX files for download - requires login"""
try: try:
gpx_path = MediaConfig.get_media_path(current_app, post_folder, 'gpx') gpx_path = MediaConfig.get_media_path(current_app, post_folder, 'gpx')
file_path = os.path.join(gpx_path, filename) file_path = os.path.join(gpx_path, filename)

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,685 @@
<?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.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,204 @@
{% extends "admin/base.html" %}
{% block title %}Analytics - Admin Dashboard{% endblock %}
{% block admin_content %}
<div class="d-sm-flex align-items-center justify-content-between mb-4">
<h1 class="h3 mb-0 text-gray-800">Analytics</h1>
</div>
<!-- Summary Cards Row -->
<div class="row">
<!-- Total Page Views Card -->
<div class="col-xl-3 col-md-6 mb-4">
<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 Page Views</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ total_views }}</div>
</div>
<div class="col-auto">
<i class="fas fa-eye fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Unique Visitors Card -->
<div class="col-xl-3 col-md-6 mb-4">
<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">
Unique Visitors</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ unique_visitors }}</div>
</div>
<div class="col-auto">
<i class="fas fa-users fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<!-- Today's Views Card -->
<div class="col-xl-3 col-md-6 mb-4">
<div class="card border-left-info 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-info text-uppercase mb-1">
Today's Views</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ today_views }}</div>
</div>
<div class="col-auto">
<i class="fas fa-calendar-day fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
<!-- This Week Views Card -->
<div class="col-xl-3 col-md-6 mb-4">
<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">
This Week</div>
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ week_views }}</div>
</div>
<div class="col-auto">
<i class="fas fa-chart-line fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Popular Pages -->
<div class="row">
<div class="col-lg-6">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Popular Pages</h6>
</div>
<div class="card-body">
{% if popular_pages %}
<div class="table-responsive">
<table class="table table-borderless">
<thead>
<tr>
<th>Page</th>
<th>Views</th>
</tr>
</thead>
<tbody>
{% for page in popular_pages %}
<tr>
<td>{{ page.path }}</td>
<td><span class="badge bg-primary">{{ page.view_count }}</span></td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No page view data available yet.</p>
{% endif %}
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="col-lg-6">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Recent Activity</h6>
</div>
<div class="card-body">
{% if recent_views %}
<div class="table-responsive">
<table class="table table-borderless">
<thead>
<tr>
<th>Time</th>
<th>Page</th>
<th>User</th>
</tr>
</thead>
<tbody>
{% for view in recent_views %}
<tr>
<td class="text-xs">{{ view.created_at.strftime('%H:%M') }}</td>
<td class="text-xs">{{ view.path }}</td>
<td class="text-xs">
{% if view.user %}
{{ view.user.nickname }}
{% else %}
Anonymous
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No recent activity data available yet.</p>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Browser Stats -->
<div class="row">
<div class="col-lg-12">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Browser Statistics</h6>
</div>
<div class="card-body">
{% if browser_stats %}
<div class="table-responsive">
<table class="table table-striped">
<thead>
<tr>
<th>Browser</th>
<th>Views</th>
<th>Percentage</th>
</tr>
</thead>
<tbody>
{% for stat in browser_stats %}
<tr>
<td>{{ stat.browser or 'Unknown' }}</td>
<td>{{ stat.view_count }}</td>
<td>
<div class="progress">
<div class="progress-bar" role="progressbar"
style="width: {{ (stat.view_count / total_views * 100) if total_views > 0 else 0 }}%">
{{ "%.1f"|format((stat.view_count / total_views * 100) if total_views > 0 else 0) }}%
</div>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">No browser data available yet.</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,233 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Admin Dashboard - Moto Adventure{% endblock %}</title>
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
<!-- Font Awesome -->
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<style>
body {
font-size: 0.875rem;
background-color: #f8f9fa;
}
.sidebar {
position: fixed;
top: 0;
bottom: 0;
left: 0;
z-index: 100;
padding: 48px 0 0;
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
background-color: #343a40;
width: 240px;
}
.sidebar-sticky {
position: relative;
top: 0;
height: calc(100vh - 48px);
padding-top: .5rem;
overflow-x: hidden;
overflow-y: auto;
}
.sidebar .nav-link {
color: rgba(255, 255, 255, .75);
padding: 10px 20px;
border-radius: 0;
}
.sidebar .nav-link:hover {
color: #fff;
background-color: rgba(255, 255, 255, .1);
}
.sidebar .nav-link.active {
color: #fff;
background-color: #007bff;
}
.sidebar-heading {
font-size: .75rem;
text-transform: uppercase;
color: rgba(255, 255, 255, .5);
padding: 15px 20px 5px;
}
.main-content {
margin-left: 240px;
padding: 20px;
min-height: 100vh;
}
.navbar-brand {
color: #fff !important;
background-color: #007bff;
padding: 10px 20px;
margin: 0;
font-weight: bold;
}
.card {
border: none;
box-shadow: 0 0.15rem 1.75rem 0 rgba(33, 40, 50, 0.15);
margin-bottom: 1.5rem;
}
.card-header {
background-color: #fff;
border-bottom: 1px solid rgba(33, 40, 50, 0.125);
font-weight: 600;
}
.border-left-primary {
border-left: 0.25rem solid #4e73df !important;
}
.border-left-success {
border-left: 0.25rem solid #1cc88a !important;
}
.border-left-warning {
border-left: 0.25rem solid #f6c23e !important;
}
.border-left-info {
border-left: 0.25rem solid #36b9cc !important;
}
.text-xs {
font-size: 0.7rem;
}
.text-gray-800 {
color: #5a5c69 !important;
}
.text-gray-300 {
color: #dddfeb !important;
}
.table th {
border-top: none;
font-weight: 600;
font-size: 0.8rem;
color: #5a5c69;
background-color: #f8f9fc;
}
@media (max-width: 768px) {
.sidebar {
transform: translateX(-100%);
transition: transform 0.3s;
}
.sidebar.show {
transform: translateX(0);
}
.main-content {
margin-left: 0;
}
}
</style>
</head>
<body>
<!-- Sidebar -->
<nav class="sidebar" id="sidebar">
<div class="navbar-brand">
<i class="fas fa-cogs"></i> Admin Panel
</div>
<div class="sidebar-sticky">
<div class="sidebar-heading">
Administration
</div>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'admin.dashboard' }}" href="{{ url_for('admin.dashboard') }}">
<i class="fas fa-tachometer-alt"></i> Dashboard
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'admin.posts' }}" href="{{ url_for('admin.posts') }}">
<i class="fas fa-file-alt"></i> Posts
{% if pending_posts_count %}
<span class="badge bg-warning text-dark ms-2">{{ pending_posts_count }}</span>
{% endif %}
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'admin.users' }}" href="{{ url_for('admin.users') }}">
<i class="fas fa-users"></i> Users
</a>
</li>
<li class="nav-item">
<a class="nav-link {{ 'active' if request.endpoint == 'admin.analytics' }}" href="{{ url_for('admin.analytics') }}">
<i class="fas fa-chart-bar"></i> Analytics
</a>
</li>
</ul>
<div class="sidebar-heading">
Quick Actions
</div>
<ul class="nav flex-column">
<li class="nav-item">
<a class="nav-link" href="{{ url_for('community.index') }}" target="_blank">
<i class="fas fa-external-link-alt"></i> View Site
</a>
</li>
<li class="nav-item">
<a class="nav-link" href="{{ url_for('admin.posts', status='pending') }}">
<i class="fas fa-clock"></i> Pending Posts
</a>
</li>
</ul>
</div>
</nav>
<!-- Main Content -->
<div class="main-content">
<!-- Mobile menu button -->
<button class="btn btn-primary d-md-none mb-3" type="button" id="sidebarToggle">
<i class="fas fa-bars"></i>
</button>
<!-- Flash Messages -->
{% 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" aria-label="Close"></button>
</div>
{% endfor %}
{% endif %}
{% endwith %}
{% block admin_content %}{% endblock %}
</div>
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
<script>
// Mobile sidebar toggle
document.getElementById('sidebarToggle')?.addEventListener('click', function() {
document.getElementById('sidebar').classList.toggle('show');
});
// Auto-refresh stats every 30 seconds
setTimeout(function() {
location.reload();
}, 30000);
</script>
</body>
</html>

View File

@@ -0,0 +1,247 @@
{% extends "admin/base.html" %}
{% block title %}Admin Dashboard - Moto Adventure{% 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">Dashboard</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="location.reload()">
<i class="fas fa-sync-alt"></i> Refresh
</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 Users</div>
<div class="h5 mb-0 fw-bold text-gray-800">{{ total_users }}</div>
</div>
<div class="col-auto">
<i class="fas fa-users 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">Published Posts</div>
<div class="h5 mb-0 fw-bold text-gray-800">{{ published_posts }}</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-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">Pending Posts</div>
<div class="h5 mb-0 fw-bold text-gray-800">
<a href="{{ url_for('admin.posts', status='pending') }}" class="text-decoration-none text-dark">
{{ pending_posts }}
</a>
</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">Active Users (30d)</div>
<div class="h5 mb-0 fw-bold text-gray-800">{{ active_users }}</div>
</div>
<div class="col-auto">
<i class="fas fa-user-check fa-2x text-gray-300"></i>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Views Statistics -->
<div class="row mb-4">
<div class="col-xl-4 col-md-6 mb-4">
<div class="card h-100">
<div class="card-header py-3">
<h6 class="m-0 fw-bold text-primary">Views Today</h6>
</div>
<div class="card-body text-center">
<div class="h3 mb-0 text-gray-800">{{ views_today }}</div>
<small class="text-muted">Yesterday: {{ views_yesterday }}</small>
</div>
</div>
</div>
<div class="col-xl-4 col-md-6 mb-4">
<div class="card h-100">
<div class="card-header py-3">
<h6 class="m-0 fw-bold text-primary">Views This Week</h6>
</div>
<div class="card-body text-center">
<div class="h3 mb-0 text-gray-800">{{ views_this_week }}</div>
</div>
</div>
</div>
<div class="col-xl-4 col-md-6 mb-4">
<div class="card h-100">
<div class="card-header py-3">
<h6 class="m-0 fw-bold text-primary">Total Engagement</h6>
</div>
<div class="card-body">
<div class="small mb-1">Comments: <span class="float-end">{{ total_comments }}</span></div>
<div class="small mb-1">Likes: <span class="float-end">{{ total_likes }}</span></div>
</div>
</div>
</div>
</div>
<!-- Content Overview -->
<div class="row">
<!-- Recent Posts -->
<div class="col-lg-6 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>
<a href="{{ url_for('admin.posts') }}" class="btn btn-sm btn-primary">View All</a>
</div>
<div class="card-body">
{% if recent_posts %}
{% for post in recent_posts %}
<div class="d-flex align-items-center mb-3 border-bottom pb-2">
<div class="flex-grow-1">
<h6 class="mb-1">
<a href="{{ url_for('admin.post_detail', post_id=post.id) }}" class="text-decoration-none">
{{ post.title[:50] }}{% if post.title|length > 50 %}...{% endif %}
</a>
</h6>
<small class="text-muted">
by {{ post.author.nickname }} • {{ post.created_at.strftime('%Y-%m-%d %H:%M') }}
</small>
</div>
<div class="ms-2">
{% if post.published %}
<span class="badge bg-success">Published</span>
{% else %}
<span class="badge bg-warning text-dark">Pending</span>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted">No recent posts</p>
{% endif %}
</div>
</div>
</div>
<!-- Most Viewed Posts -->
<div class="col-lg-6 mb-4">
<div class="card">
<div class="card-header py-3">
<h6 class="m-0 fw-bold text-primary">Most Viewed Posts</h6>
</div>
<div class="card-body">
{% if most_viewed_posts %}
{% for post_id, title, view_count in most_viewed_posts %}
<div class="d-flex justify-content-between align-items-center mb-2 border-bottom pb-1">
<div class="flex-grow-1">
<a href="{{ url_for('admin.post_detail', post_id=post_id) }}" class="text-decoration-none">
{{ title[:40] }}{% if title|length > 40 %}...{% endif %}
</a>
</div>
<span class="badge bg-info">{{ view_count }} views</span>
</div>
{% endfor %}
{% else %}
<p class="text-muted">No view data available</p>
{% endif %}
</div>
</div>
</div>
</div>
<!-- Bottom Row -->
<div class="row">
<!-- Most Viewed Pages -->
<div class="col-lg-6 mb-4">
<div class="card">
<div class="card-header py-3">
<h6 class="m-0 fw-bold text-primary">Most Viewed Pages</h6>
</div>
<div class="card-body">
{% if most_viewed_pages %}
{% for path, view_count in most_viewed_pages %}
<div class="d-flex justify-content-between align-items-center mb-2">
<code class="small">{{ path }}</code>
<span class="badge bg-secondary">{{ view_count }}</span>
</div>
{% endfor %}
{% else %}
<p class="text-muted">No page view data available</p>
{% endif %}
</div>
</div>
</div>
<!-- Recent Users -->
<div class="col-lg-6 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 Users</h6>
<a href="{{ url_for('admin.users') }}" class="btn btn-sm btn-primary">View All</a>
</div>
<div class="card-body">
{% if recent_users %}
{% for user in recent_users %}
<div class="d-flex align-items-center mb-3 border-bottom pb-2">
<div class="flex-grow-1">
<h6 class="mb-1">
<a href="{{ url_for('admin.user_detail', user_id=user.id) }}" class="text-decoration-none">
{{ user.nickname }}
</a>
</h6>
<small class="text-muted">{{ user.email }} • {{ user.created_at.strftime('%Y-%m-%d') }}</small>
</div>
<div class="ms-2">
{% if user.is_admin %}
<span class="badge bg-danger">Admin</span>
{% else %}
<span class="badge bg-primary">User</span>
{% endif %}
</div>
</div>
{% endfor %}
{% else %}
<p class="text-muted">No recent users</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,235 @@
{% extends "admin/base.html" %}
{% block title %}{{ post.title }} - Post Review{% 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">Post Review</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<a href="{{ url_for('admin.posts') }}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Back to Posts
</a>
</div>
<div class="btn-group">
{% if not post.published %}
<form method="POST" action="{{ url_for('admin.publish_post', post_id=post.id) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-success" onclick="return confirm('Publish this post?')">
<i class="fas fa-check"></i> Publish
</button>
</form>
{% else %}
<form method="POST" action="{{ url_for('admin.unpublish_post', post_id=post.id) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-warning" onclick="return confirm('Unpublish this post?')">
<i class="fas fa-times"></i> Unpublish
</button>
</form>
{% endif %}
<form method="POST" action="{{ url_for('admin.delete_post', post_id=post.id) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger"
onclick="return confirm('Are you sure you want to delete this post? This action cannot be undone.')">
<i class="fas fa-trash"></i> Delete
</button>
</form>
</div>
</div>
</div>
<div class="row">
<!-- Post Details -->
<div class="col-lg-8">
<div class="card mb-4">
<div class="card-header py-3">
<h6 class="m-0 fw-bold text-primary">Post Content</h6>
</div>
<div class="card-body">
<h3>{{ post.title }}</h3>
{% if post.subtitle %}
<h5 class="text-muted mb-3">{{ post.subtitle }}</h5>
{% endif %}
<div class="mb-3">
<span class="badge bg-info">{{ post.get_difficulty_label() }}</span>
{% if post.published %}
<span class="badge bg-success">Published</span>
{% else %}
<span class="badge bg-warning text-dark">Pending Review</span>
{% endif %}
</div>
<div class="post-content">
{{ post.content|replace('\n', '<br>')|safe }}
</div>
</div>
</div>
<!-- Post Images -->
{% if post.images.count() > 0 %}
<div class="card mb-4">
<div class="card-header py-3">
<h6 class="m-0 fw-bold text-primary">Images ({{ post.images.count() }})</h6>
</div>
<div class="card-body">
<div class="row">
{% for image in post.images %}
<div class="col-md-4 mb-3">
<div class="card">
<img src="{{ image.get_url() }}" class="card-img-top" alt="{{ image.original_name }}"
style="height: 200px; object-fit: cover;">
<div class="card-body p-2">
<small class="text-muted">{{ image.original_name }}</small>
{% if image.is_cover %}
<span class="badge bg-primary badge-sm">Cover</span>
{% endif %}
{% if image.description %}
<p class="card-text small">{{ image.description }}</p>
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
</div>
</div>
{% endif %}
<!-- GPX Files -->
{% if post.gpx_files.count() > 0 %}
<div class="card mb-4">
<div class="card-header py-3">
<h6 class="m-0 fw-bold text-primary">GPX Files ({{ post.gpx_files.count() }})</h6>
</div>
<div class="card-body">
{% for gpx_file in post.gpx_files %}
<div class="d-flex justify-content-between align-items-center mb-2">
<div>
<i class="fas fa-route text-primary"></i>
<strong>{{ gpx_file.original_name }}</strong>
<small class="text-muted">({{ "%.1f"|format(gpx_file.size / 1024) }} KB)</small>
</div>
<a href="{{ gpx_file.get_url() }}" class="btn btn-sm btn-outline-primary" download>
<i class="fas fa-download"></i> Download
</a>
</div>
{% endfor %}
</div>
</div>
{% endif %}
</div>
<!-- Sidebar -->
<div class="col-lg-4">
<!-- Post Metadata -->
<div class="card mb-4">
<div class="card-header py-3">
<h6 class="m-0 fw-bold text-primary">Post Information</h6>
</div>
<div class="card-body">
<table class="table table-sm table-borderless mb-0">
<tr>
<td><strong>Author:</strong></td>
<td>
<a href="{{ url_for('admin.user_detail', user_id=post.author.id) }}">
{{ post.author.nickname }}
</a>
</td>
</tr>
<tr>
<td><strong>Created:</strong></td>
<td><small>{{ post.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</small></td>
</tr>
<tr>
<td><strong>Updated:</strong></td>
<td><small>{{ post.updated_at.strftime('%Y-%m-%d %H:%M:%S') }}</small></td>
</tr>
<tr>
<td><strong>Difficulty:</strong></td>
<td>{{ post.get_difficulty_label() }} ({{ post.difficulty }}/5)</td>
</tr>
<tr>
<td><strong>Status:</strong></td>
<td>
{% if post.published %}
<span class="badge bg-success">Published</span>
{% else %}
<span class="badge bg-warning text-dark">Pending</span>
{% endif %}
</td>
</tr>
<tr>
<td><strong>Media Folder:</strong></td>
<td>
{% if post.media_folder %}
<code>{{ post.media_folder }}</code>
{% else %}
<em class="text-muted">None</em>
{% endif %}
</td>
</tr>
</table>
</div>
</div>
<!-- Post Statistics -->
<div class="card mb-4">
<div class="card-header py-3">
<h6 class="m-0 fw-bold text-primary">Statistics</h6>
</div>
<div class="card-body">
<table class="table table-sm table-borderless mb-0">
<tr>
<td><strong>Comments:</strong></td>
<td>{{ post.comments.count() }}</td>
</tr>
<tr>
<td><strong>Likes:</strong></td>
<td>{{ post.get_like_count() }}</td>
</tr>
<tr>
<td><strong>Images:</strong></td>
<td>{{ post.images.count() }}</td>
</tr>
<tr>
<td><strong>GPX Files:</strong></td>
<td>{{ post.gpx_files.count() }}</td>
</tr>
<tr>
<td><strong>Views:</strong></td>
<td>{{ post.page_views|length if post.page_views else 0 }}</td>
</tr>
</table>
</div>
</div>
<!-- Quick Actions -->
<div class="card">
<div class="card-header py-3">
<h6 class="m-0 fw-bold text-primary">Quick Actions</h6>
</div>
<div class="card-body">
{% if post.published %}
<a href="{{ url_for('community.post_detail', post_id=post.id) }}"
class="btn btn-sm btn-primary w-100 mb-2" target="_blank">
<i class="fas fa-external-link-alt"></i> View on Site
</a>
{% endif %}
<a href="{{ url_for('admin.user_detail', user_id=post.author.id) }}"
class="btn btn-sm btn-info w-100">
<i class="fas fa-user"></i> View Author
</a>
</div>
</div>
</div>
</div>
<style>
.post-content {
line-height: 1.6;
white-space: pre-wrap;
}
.badge-sm {
font-size: 0.65em;
}
</style>
{% endblock %}

View File

@@ -0,0 +1,176 @@
{% extends "admin/base.html" %}
{% block title %}Post Management - 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">Posts</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group me-2">
<a href="{{ url_for('admin.posts', status='all') }}"
class="btn btn-sm {{ 'btn-primary' if status == 'all' else 'btn-outline-secondary' }}">
All Posts
</a>
<a href="{{ url_for('admin.posts', status='published') }}"
class="btn btn-sm {{ 'btn-primary' if status == 'published' else 'btn-outline-secondary' }}">
Published
</a>
<a href="{{ url_for('admin.posts', status='pending') }}"
class="btn btn-sm {{ 'btn-primary' if status == 'pending' else 'btn-outline-secondary' }}">
Pending Review
</a>
</div>
</div>
</div>
{% if posts.items %}
<div class="card">
<div class="card-header py-3">
<h6 class="m-0 fw-bold text-primary">
{% if status == 'pending' %}
Posts Pending Review ({{ posts.total }})
{% elif status == 'published' %}
Published Posts ({{ posts.total }})
{% else %}
All Posts ({{ posts.total }})
{% endif %}
</h6>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="width: 40%;">Title</th>
<th style="width: 15%;">Author</th>
<th style="width: 10%;">Status</th>
<th style="width: 10%;">Difficulty</th>
<th style="width: 15%;">Created</th>
<th style="width: 10%;">Actions</th>
</tr>
</thead>
<tbody>
{% for post in posts.items %}
<tr>
<td>
<div>
<a href="{{ url_for('admin.post_detail', post_id=post.id) }}" class="text-decoration-none fw-bold">
{{ post.title }}
</a>
{% if post.subtitle %}
<br><small class="text-muted">{{ post.subtitle[:80] }}{% if post.subtitle|length > 80 %}...{% endif %}</small>
{% endif %}
</div>
</td>
<td>
<a href="{{ url_for('admin.user_detail', user_id=post.author.id) }}" class="text-decoration-none">
{{ post.author.nickname }}
</a>
</td>
<td>
{% if post.published %}
<span class="badge bg-success">Published</span>
{% else %}
<span class="badge bg-warning text-dark">Pending</span>
{% endif %}
</td>
<td>
<span class="badge bg-info">{{ post.get_difficulty_label() }}</span>
</td>
<td>
<small>{{ post.created_at.strftime('%Y-%m-%d<br>%H:%M')|safe }}</small>
</td>
<td>
<div class="btn-group" role="group">
<a href="{{ url_for('admin.post_detail', post_id=post.id) }}"
class="btn btn-sm btn-outline-info" title="View Details">
<i class="fas fa-eye"></i>
</a>
{% if not post.published %}
<form method="POST" action="{{ url_for('admin.publish_post', post_id=post.id) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-success" title="Publish"
onclick="return confirm('Publish this post?')">
<i class="fas fa-check"></i>
</button>
</form>
{% else %}
<form method="POST" action="{{ url_for('admin.unpublish_post', post_id=post.id) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-warning" title="Unpublish"
onclick="return confirm('Unpublish this post?')">
<i class="fas fa-times"></i>
</button>
</form>
{% endif %}
<form method="POST" action="{{ url_for('admin.delete_post', post_id=post.id) }}" class="d-inline">
<button type="submit" class="btn btn-sm btn-danger" title="Delete"
onclick="return confirm('Are you sure you want to delete this post? This action cannot be undone.')">
<i class="fas fa-trash"></i>
</button>
</form>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Pagination -->
{% if posts.pages > 1 %}
<nav aria-label="Posts pagination" class="mt-4">
<ul class="pagination justify-content-center">
{% if posts.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.posts', page=posts.prev_num, status=status) }}">Previous</a>
</li>
{% endif %}
{% for page_num in posts.iter_pages() %}
{% if page_num %}
{% if page_num != posts.page %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.posts', 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 posts.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.posts', page=posts.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-file-alt fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No posts found</h5>
<p class="text-muted">
{% if status == 'pending' %}
There are no posts waiting for review.
{% elif status == 'published' %}
No posts have been published yet.
{% else %}
No posts have been created yet.
{% endif %}
</p>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,185 @@
{% extends "admin/base.html" %}
{% block title %}{{ user.nickname }} - User Details{% endblock %}
{% block admin_content %}
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
<h1 class="h2">User Details</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<div class="btn-group mr-2">
<a href="{{ url_for('admin.users') }}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Back to Users
</a>
</div>
</div>
</div>
<div class="row">
<!-- User Information -->
<div class="col-lg-4">
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">User Information</h6>
</div>
<div class="card-body">
<table class="table table-sm table-borderless">
<tr>
<td><strong>Nickname:</strong></td>
<td>{{ user.nickname }}</td>
</tr>
<tr>
<td><strong>Email:</strong></td>
<td>{{ user.email }}</td>
</tr>
<tr>
<td><strong>Role:</strong></td>
<td>
{% if user.is_admin %}
<span class="badge badge-danger">Admin</span>
{% else %}
<span class="badge badge-primary">User</span>
{% endif %}
</td>
</tr>
<tr>
<td><strong>Status:</strong></td>
<td>
{% if user.is_active %}
<span class="badge badge-success">Active</span>
{% else %}
<span class="badge badge-secondary">Inactive</span>
{% endif %}
</td>
</tr>
<tr>
<td><strong>Joined:</strong></td>
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
</tr>
<tr>
<td><strong>Last Updated:</strong></td>
<td>{{ user.updated_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
</tr>
</table>
</div>
</div>
<!-- User Statistics -->
<div class="card shadow">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Statistics</h6>
</div>
<div class="card-body">
<table class="table table-sm table-borderless">
<tr>
<td><strong>Total Posts:</strong></td>
<td>{{ user_posts|length }}</td>
</tr>
<tr>
<td><strong>Published Posts:</strong></td>
<td>{{ user_posts|selectattr('published')|list|length }}</td>
</tr>
<tr>
<td><strong>Pending Posts:</strong></td>
<td>{{ user_posts|rejectattr('published')|list|length }}</td>
</tr>
<tr>
<td><strong>Comments:</strong></td>
<td>{{ user_comments|length }}</td>
</tr>
<tr>
<td><strong>Likes Given:</strong></td>
<td>{{ user.likes.count() }}</td>
</tr>
</table>
</div>
</div>
</div>
<!-- User Content -->
<div class="col-lg-8">
<!-- User Posts -->
<div class="card shadow mb-4">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Posts ({{ user_posts|length }})</h6>
</div>
<div class="card-body">
{% if user_posts %}
<div class="table-responsive">
<table class="table table-sm table-hover">
<thead>
<tr>
<th>Title</th>
<th>Status</th>
<th>Created</th>
<th>Actions</th>
</tr>
</thead>
<tbody>
{% for post in user_posts %}
<tr>
<td>
<a href="{{ url_for('admin.post_detail', post_id=post.id) }}" class="text-decoration-none">
{{ post.title[:50] }}{% if post.title|length > 50 %}...{% endif %}
</a>
</td>
<td>
{% if post.published %}
<span class="badge badge-success">Published</span>
{% else %}
<span class="badge badge-warning">Pending</span>
{% endif %}
</td>
<td>
<small>{{ post.created_at.strftime('%Y-%m-%d') }}</small>
</td>
<td>
<a href="{{ url_for('admin.post_detail', post_id=post.id) }}"
class="btn btn-sm btn-outline-info">
<i class="fas fa-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<p class="text-muted">This user has not created any posts yet.</p>
{% endif %}
</div>
</div>
<!-- Recent Comments -->
<div class="card shadow">
<div class="card-header py-3">
<h6 class="m-0 font-weight-bold text-primary">Recent Comments ({{ user_comments|length }})</h6>
</div>
<div class="card-body">
{% if user_comments %}
{% for comment in user_comments[:10] %}
<div class="border-bottom mb-3 pb-3">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<p class="mb-1">{{ comment.content[:200] }}{% if comment.content|length > 200 %}...{% endif %}</p>
<small class="text-muted">
On post:
<a href="{{ url_for('admin.post_detail', post_id=comment.post.id) }}">
{{ comment.post.title }}
</a>
</small>
</div>
<small class="text-muted">{{ comment.created_at.strftime('%Y-%m-%d') }}</small>
</div>
</div>
{% endfor %}
{% if user_comments|length > 10 %}
<p class="text-muted text-center">... and {{ user_comments|length - 10 }} more comments</p>
{% endif %}
{% else %}
<p class="text-muted">This user has not made any comments yet.</p>
{% endif %}
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -0,0 +1,137 @@
{% extends "admin/base.html" %}
{% block title %}User Management - 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">Users</h1>
<div class="btn-toolbar mb-2 mb-md-0">
<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 users.items %}
<div class="card">
<div class="card-header py-3">
<h6 class="m-0 fw-bold text-primary">Users ({{ users.total }})</h6>
</div>
<div class="card-body p-0">
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead>
<tr>
<th style="width: 20%;">User</th>
<th style="width: 25%;">Email</th>
<th style="width: 10%;">Role</th>
<th style="width: 15%;">Posts</th>
<th style="width: 10%;">Status</th>
<th style="width: 10%;">Joined</th>
<th style="width: 10%;">Actions</th>
</tr>
</thead>
<tbody>
{% for user in users.items %}
<tr>
<td>
<div>
<a href="{{ url_for('admin.user_detail', user_id=user.id) }}" class="text-decoration-none fw-bold">
{{ user.nickname }}
</a>
</div>
</td>
<td>
<small>{{ user.email }}</small>
</td>
<td>
{% if user.is_admin %}
<span class="badge bg-danger">Admin</span>
{% else %}
<span class="badge bg-primary">User</span>
{% endif %}
</td>
<td>
<span class="badge bg-secondary">{{ user.posts.count() }}</span>
{% if user.posts.filter_by(published=True).count() > 0 %}
<br><small class="text-success">({{ user.posts.filter_by(published=True).count() }} published)</small>
{% endif %}
</td>
<td>
{% if user.is_active %}
<span class="badge bg-success">Active</span>
{% else %}
<span class="badge bg-secondary">Inactive</span>
{% endif %}
</td>
<td>
<small>{{ user.created_at.strftime('%Y-%m-%d') }}</small>
</td>
<td>
<div class="btn-group" role="group">
<a href="{{ url_for('admin.user_detail', user_id=user.id) }}"
class="btn btn-sm btn-outline-info" title="View Details">
<i class="fas fa-eye"></i>
</a>
{% if not user.is_admin or current_user.id != user.id %}
<button class="btn btn-sm btn-outline-warning" title="Toggle Status" disabled>
<i class="fas fa-toggle-on"></i>
</button>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
</div>
<!-- Pagination -->
{% if users.pages > 1 %}
<nav aria-label="Users pagination" class="mt-4">
<ul class="pagination justify-content-center">
{% if users.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.users', page=users.prev_num) }}">Previous</a>
</li>
{% endif %}
{% for page_num in users.iter_pages() %}
{% if page_num %}
{% if page_num != users.page %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.users', page=page_num) }}">{{ 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 users.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('admin.users', page=users.next_num) }}">Next</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<div class="card">
<div class="card-body text-center py-5">
<i class="fas fa-users fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No users found</h5>
<p class="text-muted">No users have registered yet.</p>
</div>
</div>
{% endif %}
{% endblock %}

View File

@@ -29,6 +29,9 @@
<a href="{{ url_for('community.new_post') }}" class="bg-gradient-to-r from-green-500 to-emerald-500 text-white px-4 py-2 rounded-lg hover:from-green-400 hover:to-emerald-400 transition transform hover:scale-105"> <a href="{{ url_for('community.new_post') }}" class="bg-gradient-to-r from-green-500 to-emerald-500 text-white px-4 py-2 rounded-lg hover:from-green-400 hover:to-emerald-400 transition transform hover:scale-105">
<i class="fas fa-plus mr-2"></i>Share Adventure <i class="fas fa-plus mr-2"></i>Share Adventure
</a> </a>
<a href="{{ url_for('community.profile') }}" class="text-white hover:text-blue-200 transition">
<i class="fas fa-user mr-1"></i>My Profile
</a>
{% if current_user.is_admin %} {% if current_user.is_admin %}
<a href="{{ url_for('admin.dashboard') }}" class="text-white hover:text-yellow-200 transition"> <a href="{{ url_for('admin.dashboard') }}" class="text-white hover:text-yellow-200 transition">
<i class="fas fa-cog mr-1"></i>Admin <i class="fas fa-cog mr-1"></i>Admin
@@ -61,6 +64,9 @@
<a href="{{ url_for('community.new_post') }}" class="text-white block px-3 py-2 hover:bg-green-600 rounded"> <a href="{{ url_for('community.new_post') }}" class="text-white block px-3 py-2 hover:bg-green-600 rounded">
<i class="fas fa-plus mr-2"></i>New Post <i class="fas fa-plus mr-2"></i>New Post
</a> </a>
<a href="{{ url_for('community.profile') }}" class="text-white block px-3 py-2 hover:bg-blue-600 rounded">
<i class="fas fa-user mr-1"></i>My Profile
</a>
{% if current_user.is_admin %} {% if current_user.is_admin %}
<a href="{{ url_for('admin.dashboard') }}" class="text-white block px-3 py-2 hover:bg-yellow-600 rounded"> <a href="{{ url_for('admin.dashboard') }}" class="text-white block px-3 py-2 hover:bg-yellow-600 rounded">
<i class="fas fa-cog mr-1"></i>Admin <i class="fas fa-cog mr-1"></i>Admin

View File

@@ -0,0 +1,423 @@
{% extends "base.html" %}
{% block title %}Edit Adventure - {{ post.title }}{% 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>
<!-- 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>
</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>
</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>
</ul>
</div>
<div class="mb-3">
<h6><i class="fas fa-star text-warning"></i> Difficulty Levels</h6>
<div class="small">
<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>
<div><strong>Hard:</strong> Extreme conditions</div>
<div><strong>Expert:</strong> Dangerous, experts only</div>
</div>
</div>
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle"></i>
<strong>Note:</strong> Updating your post will reset its status to "pending review."
</div>
</div>
</div>
</div>
</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
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<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
</button>
</div>
</div>
</div>
</div>
<!-- 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]);
}
}
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 = '';
}
}
}
function previewPost() {
// Get form data
const title = document.getElementById('title').value;
const subtitle = document.getElementById('subtitle').value;
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'
};
// Format content (simple markdown-like formatting)
const formattedContent = content
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/\n/g, '<br>');
// Generate preview HTML
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'}
</span>
<small>By {{ current_user.nickname }} • Updated today</small>
</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>
</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>
</div>
</div>
</div>
</div>
`;
// Show preview in modal
document.getElementById('previewContent').innerHTML = previewHTML;
const modal = new bootstrap.Modal(document.getElementById('previewModal'));
modal.show();
}
function submitForm() {
document.getElementById('editPostForm').submit();
}
// Form submission with AJAX
document.getElementById('editPostForm').addEventListener('submit', function(e) {
e.preventDefault();
const formData = new FormData(this);
const submitButton = this.querySelector('button[type="submit"]');
const originalText = submitButton.innerHTML;
// Show loading state
submitButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Updating...';
submitButton.disabled = true;
fetch(this.action, {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Show success message
const alert = document.createElement('div');
alert.className = 'alert alert-success alert-dismissible fade show';
alert.innerHTML = `
<i class="fas fa-check-circle"></i> ${data.message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
this.insertBefore(alert, this.firstChild);
// Redirect after delay
setTimeout(() => {
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.innerHTML = `
<i class="fas fa-exclamation-triangle"></i> ${data.error}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
this.insertBefore(alert, this.firstChild);
}
})
.catch(error => {
console.error('Error:', error);
const alert = document.createElement('div');
alert.className = 'alert alert-danger alert-dismissible fade show';
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>
`;
this.insertBefore(alert, this.firstChild);
})
.finally(() => {
// Restore button state
submitButton.innerHTML = originalText;
submitButton.disabled = false;
});
});
</script>
{% endblock %}

View File

@@ -177,13 +177,13 @@
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path>
</svg> </svg>
{{ post.comments|length }} {{ post.comments.count() }}
</span> </span>
<span class="flex items-center"> <span class="flex items-center">
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"></path>
</svg> </svg>
{{ post.likes|length }} {{ post.likes.count() }}
</span> </span>
</div> </div>

View File

@@ -130,7 +130,7 @@
</div> </div>
<!-- Main Form --> <!-- Main Form -->
<form id="postForm" enctype="multipart/form-data" class="space-y-6"> <form id="adventure-form" method="POST" action="{{ url_for('community.new_post') }}" enctype="multipart/form-data" class="space-y-6">
<!-- Basic Information Section --> <!-- Basic Information Section -->
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20"> <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> <h2 class="text-2xl font-bold text-white mb-6">📝 Basic Information</h2>
@@ -228,7 +228,11 @@
</div> </div>
<!-- Submit --> <!-- Submit -->
<div class="text-center"> <div class="text-center space-x-4">
<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 Adventure
</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"> <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> <i class="fas fa-paper-plane mr-3"></i>
Share Adventure Share Adventure
@@ -242,6 +246,38 @@
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
let sectionCounter = 0; let sectionCounter = 0;
// Populate form from URL parameters
const urlParams = new URLSearchParams(window.location.search);
if (urlParams.get('title')) {
document.getElementById('title').value = urlParams.get('title');
}
if (urlParams.get('subtitle')) {
document.getElementById('subtitle').value = urlParams.get('subtitle');
}
if (urlParams.get('difficulty')) {
document.getElementById('difficulty').value = urlParams.get('difficulty');
}
if (urlParams.get('cover_picture')) {
// Note: This would be the filename, but we can't pre-populate file inputs for security reasons
// We'll show a message instead
const coverUploadArea = document.querySelector('.cover-upload-area');
const coverContent = document.querySelector('.cover-upload-content');
coverContent.innerHTML = `
<div class="text-4xl mb-2">📸</div>
<p class="text-yellow-300 mb-2">Cover image suggested: ${urlParams.get('cover_picture')}</p>
<p class="text-white/80 mb-2">Click to upload this or another cover image</p>
<p class="text-sm text-white/60">Recommended: 1920x1080 pixels</p>
`;
}
if (urlParams.get('gpx_file')) {
// Similar note for GPX file
const gpxLabel = document.querySelector('label[for="gpx-file"]');
gpxLabel.innerHTML = `
<i class="fas fa-upload mr-2"></i>
Choose GPX File (suggested: ${urlParams.get('gpx_file')})
`;
}
// Cover picture upload // Cover picture upload
const coverUploadArea = document.querySelector('.cover-upload-area'); const coverUploadArea = document.querySelector('.cover-upload-area');
const coverInput = document.getElementById('cover_picture'); const coverInput = document.getElementById('cover_picture');
@@ -531,12 +567,8 @@ document.addEventListener('DOMContentLoaded', function() {
return; return;
} }
// Check if at least one section is saved // Check if at least one section is saved or allow empty content for now
const savedSections = document.querySelectorAll('.content-section.saved'); const savedSections = document.querySelectorAll('.content-section.saved');
if (savedSections.length === 0) {
alert('Please save at least one content section.');
return;
}
// Collect all form data // Collect all form data
const formData = new FormData(); const formData = new FormData();
@@ -546,50 +578,245 @@ document.addEventListener('DOMContentLoaded', function() {
// Collect content from saved sections // Collect content from saved sections
let fullContent = ''; let fullContent = '';
savedSections.forEach((section, index) => { if (savedSections.length > 0) {
const highlights = Array.from(section.querySelectorAll('.saved-highlights .highlight-display')) savedSections.forEach((section, index) => {
.map(span => span.textContent); const highlights = Array.from(section.querySelectorAll('.saved-highlights .highlight-display'))
const text = section.querySelector('.saved-text').textContent; .map(span => span.textContent);
const text = section.querySelector('.saved-text').textContent;
if (highlights.length > 0) { if (highlights.length > 0) {
fullContent += highlights.map(h => `**${h}**`).join(' ') + '\n\n'; fullContent += highlights.map(h => `**${h}**`).join(' ') + '\n\n';
} }
if (text) { if (text) {
fullContent += text + '\n\n'; fullContent += text + '\n\n';
} }
}); });
} else {
// If no sections saved, use a default message
fullContent = 'Adventure details will be added soon.';
}
formData.append('content', fullContent); formData.append('content', fullContent);
// Add cover picture if selected
const coverFile = document.getElementById('cover_picture').files[0];
if (coverFile) {
formData.append('cover_picture', coverFile);
}
// Add GPX file if selected // Add GPX file if selected
const gpxFile = document.getElementById('gpx-file').files[0]; const gpxFile = document.getElementById('gpx-file').files[0];
if (gpxFile) { if (gpxFile) {
formData.append('gpx_file', gpxFile); formData.append('gpx_file', gpxFile);
} }
// Add images (simplified - in a real implementation, you'd handle this properly) // Show loading state
const allImages = document.querySelectorAll('.saved-images img'); const submitBtn = document.querySelector('button[type="submit"]');
// Note: This is a simplified version. In a real implementation, const originalText = submitBtn.innerHTML;
// you'd need to properly handle the image files submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-3"></i>Creating Adventure...';
submitBtn.disabled = true;
// Submit form // Submit form
fetch('{{ url_for("community.new_post") }}', { fetch('{{ url_for("community.new_post") }}', {
method: 'POST', method: 'POST',
body: formData body: formData
}) })
.then(response => response.json()) .then(response => {
.then(data => { if (response.redirected) {
if (data.success) { // Follow redirect
window.location.href = data.redirect_url; window.location.href = response.url;
} else if (response.ok) {
// Check if it's JSON response
return response.text().then(text => {
try {
const data = JSON.parse(text);
if (data.success) {
window.location.href = data.redirect_url || '/community/';
} else {
throw new Error(data.error || 'Unknown error');
}
} catch (e) {
// Not JSON, probably HTML redirect response
window.location.href = '/community/';
}
});
} else { } else {
alert('Error creating post: ' + data.error); throw new Error('Server error');
} }
}) })
.catch(error => { .catch(error => {
console.error('Error:', error); console.error('Error:', error);
alert('An error occurred while creating your post.'); alert('An error occurred while creating your post. Please try again.');
// Restore button
submitBtn.innerHTML = originalText;
submitBtn.disabled = false;
}); });
}); });
// Preview function
function previewPost() {
// Get form data
const title = document.getElementById('title').value;
const subtitle = document.getElementById('subtitle').value;
const content = document.getElementById('content').value;
const difficulty = document.getElementById('difficulty').value;
// Get difficulty stars and labels
const difficultyStars = '⭐'.repeat(difficulty);
const difficultyLabels = {
'1': 'Easy - Beginner friendly',
'2': 'Moderate - Some experience needed',
'3': 'Challenging - Good skills required',
'4': 'Hard - Advanced riders only',
'5': 'Expert - Extreme difficulty'
};
// Format content (simple markdown-like formatting)
const formattedContent = content
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
.replace(/\*(.*?)\*/g, '<em>$1</em>')
.replace(/\n/g, '<br>');
// Get cover image preview if available
const coverInput = document.getElementById('cover_picture');
let coverImageHtml = '';
if (coverInput.files && coverInput.files[0]) {
const reader = new FileReader();
reader.onload = function(e) {
showPreviewModal(title, subtitle, formattedContent, difficulty, difficultyStars, difficultyLabels, e.target.result);
};
reader.readAsDataURL(coverInput.files[0]);
} else {
showPreviewModal(title, subtitle, formattedContent, difficulty, difficultyStars, difficultyLabels, null);
}
}
function showPreviewModal(title, subtitle, formattedContent, difficulty, difficultyStars, difficultyLabels, coverImageSrc) {
// Create cover image section
let coverImageHtml = '';
if (coverImageSrc) {
coverImageHtml = `
<div class="position-relative mb-4">
<img src="${coverImageSrc}" alt="Cover preview" class="w-100 rounded" style="max-height: 300px; object-fit: cover;">
<div class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-end" style="background: linear-gradient(transparent, rgba(0,0,0,0.6));">
<div class="p-4 text-white">
<h1 class="display-4 mb-2">${title || 'Your Adventure Title'}</h1>
${subtitle ? `<p class="lead mb-0">${subtitle}</p>` : ''}
</div>
</div>
</div>
`;
} else {
coverImageHtml = `
<div class="bg-primary text-white p-4 rounded mb-4">
<h1 class="display-4 mb-2">${title || 'Your Adventure Title'}</h1>
${subtitle ? `<p class="lead mb-0">${subtitle}</p>` : ''}
</div>
`;
}
// Generate preview HTML
const previewHTML = `
<div class="post-preview">
${coverImageHtml}
<div class="row">
<div class="col-lg-8">
<div class="adventure-content">
<h3><i class="fas fa-book text-primary"></i> Adventure Story</h3>
<div class="content-text mb-4">
${formattedContent || '<em class="text-muted">No content provided yet...</em>'}
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card">
<div class="card-header bg-light">
<h5 class="mb-0"><i class="fas fa-info-circle"></i> Adventure Details</h5>
</div>
<div class="card-body">
<div class="mb-3">
<strong>Difficulty:</strong><br>
<span class="badge bg-warning text-dark fs-6">
${difficultyStars} ${difficultyLabels[difficulty] || 'Not set'}
</span>
</div>
<div class="mb-3">
<strong>Status:</strong><br>
<span class="badge bg-warning">Pending Review</span>
</div>
<div class="mb-3">
<strong>Author:</strong><br>
{{ current_user.nickname }}
</div>
<div>
<strong>Created:</strong><br>
Today
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-header bg-info text-white">
<h6 class="mb-0"><i class="fas fa-map-marked-alt"></i> Route Information</h6>
</div>
<div class="card-body">
${document.getElementById('gpx_file').files.length > 0 ?
'<div class="alert alert-success small"><i class="fas fa-check"></i> GPS track will be displayed on map</div>' :
'<div class="alert alert-info small"><i class="fas fa-info"></i> No GPS track uploaded</div>'
}
</div>
</div>
</div>
</div>
</div>
`;
// Show preview in modal
showModal('Adventure Preview', previewHTML);
}
function showModal(title, content) {
// Create modal if it doesn't exist
let modal = document.getElementById('previewModal');
if (!modal) {
modal = document.createElement('div');
modal.innerHTML = `
<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> ${title}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div id="previewContent">${content}</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times"></i> Close Preview
</button>
<button type="button" class="btn btn-success" onclick="document.getElementById('createPostForm').submit()">
<i class="fas fa-paper-plane"></i> Looks Good - Share Adventure
</button>
</div>
</div>
</div>
</div>
`;
document.body.appendChild(modal);
} else {
document.getElementById('previewContent').innerHTML = content;
document.getElementById('previewModalLabel').innerHTML = `<i class="fas fa-eye"></i> ${title}`;
}
// Show the modal
const bootstrapModal = new bootstrap.Modal(document.getElementById('previewModal'));
bootstrapModal.show();
}
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,297 @@
{% extends "base.html" %}
{% block title %}My Profile - {{ current_user.nickname }}{% endblock %}
{% block content %}
<div class="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-teal-900 pt-16">
<!-- Profile Header -->
<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">
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-8 border border-white/20">
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
<div class="flex items-center space-x-6">
<div class="w-20 h-20 bg-gradient-to-r from-orange-500 to-red-600 rounded-full flex items-center justify-center text-white font-bold text-3xl">
{{ current_user.nickname[0].upper() }}
</div>
<div>
<h1 class="text-3xl font-bold text-white mb-2">{{ current_user.nickname }}</h1>
<p class="text-blue-200 mb-1">
<i class="fas fa-envelope mr-2"></i>{{ current_user.email }}
</p>
<p class="text-blue-200">
<i class="fas fa-calendar mr-2"></i>Riding since {{ current_user.created_at.strftime('%B %Y') }}
</p>
</div>
</div>
<div>
<a href="{{ url_for('community.new_post') }}"
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 transform hover:scale-105">
<i class="fas fa-plus mr-2"></i>Share New Adventure
</a>
</div>
</div>
</div>
</div>
</div>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-12 -mt-8">
<!-- Adventure 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-green-600 to-emerald-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-check-circle text-3xl mr-4"></i>
<div>
<div class="text-3xl font-bold">{{ published_count }}</div>
<div class="text-green-100">Published Adventures</div>
</div>
</div>
</div>
<div class="p-4">
<p class="text-gray-600 text-sm">Visible to the community</p>
</div>
</div>
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="bg-gradient-to-r from-yellow-600 to-orange-600 p-6">
<div class="flex items-center text-white">
<i class="fas fa-clock text-3xl mr-4"></i>
<div>
<div class="text-3xl font-bold">{{ pending_count }}</div>
<div class="text-yellow-100">Awaiting Review</div>
</div>
</div>
</div>
<div class="p-4">
<p class="text-gray-600 text-sm">Pending admin approval</p>
</div>
</div>
</div>
<!-- Adventures Collection Header -->
<div class="bg-white rounded-2xl shadow-xl p-6 mb-8">
<div class="flex items-center">
<i class="fas fa-mountain text-3xl text-blue-600 mr-4"></i>
<div>
<h2 class="text-2xl font-bold text-gray-900">My Adventure Collection</h2>
<p class="text-gray-600">Manage and share your motorcycle adventures</p>
</div>
</div>
</div>
<!-- Adventures Grid -->
{% if posts.items %}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{% for post in posts.items %}
<div class="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl hover:scale-105">
<!-- Adventure Image -->
{% set cover_image = post.images | selectattr('is_cover', 'equalto', True) | first %}
{% if cover_image %}
<div class="relative">
<img src="{{ cover_image.get_thumbnail_url() }}"
alt="{{ post.title }}"
class="w-full h-48 object-cover">
<div class="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent"></div>
<div class="absolute top-3 right-3">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-semibold
{{ 'bg-green-500 text-white' if post.published else 'bg-yellow-500 text-black' }}">
{% if post.published %}
<i class="fas fa-check-circle mr-1"></i>Live
{% else %}
<i class="fas fa-clock mr-1"></i>Review
{% endif %}
</span>
</div>
</div>
{% else %}
<div class="h-48 bg-gradient-to-br from-blue-600 to-purple-600 flex items-center justify-center relative">
<i class="fas fa-mountain text-white text-6xl opacity-50"></i>
<div class="absolute top-3 right-3">
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-semibold
{{ 'bg-green-500 text-white' if post.published else 'bg-yellow-500 text-black' }}">
{% if post.published %}
<i class="fas fa-check-circle mr-1"></i>Live
{% else %}
<i class="fas fa-clock mr-1"></i>Review
{% endif %}
</span>
</div>
</div>
{% endif %}
<div class="p-6">
<!-- Title and Subtitle -->
<h3 class="text-xl font-bold text-gray-900 mb-2">{{ post.title }}</h3>
{% if post.subtitle %}
<p class="text-gray-600 text-sm mb-3">{{ post.subtitle }}</p>
{% endif %}
<!-- Difficulty Badge -->
<div class="mb-4">
<span class="inline-flex items-center px-3 py-1 rounded-full bg-gradient-to-r from-yellow-500 to-orange-500 text-white text-sm font-semibold">
{% for i in range(post.difficulty) %}
<i class="fas fa-star mr-1"></i>
{% endfor %}
{{ post.get_difficulty_label() }}
</span>
</div>
<!-- Content Preview -->
<p class="text-gray-600 text-sm mb-4 h-12 overflow-hidden">
{{ (post.content[:100] + '...') if post.content|length > 100 else post.content }}
</p>
<!-- Adventure Metadata -->
<div class="bg-gray-50 rounded-lg p-4 mb-4">
<div class="grid grid-cols-3 gap-4 text-center">
<div>
<i class="fas fa-calendar text-blue-500 mb-1"></i>
<div class="text-sm font-semibold">{{ post.created_at.strftime('%m/%d') }}</div>
<div class="text-xs text-gray-500">Created</div>
</div>
<div>
<i class="fas fa-images text-green-500 mb-1"></i>
<div class="text-sm font-semibold">{{ post.images.count() }}</div>
<div class="text-xs text-gray-500">Photos</div>
</div>
<div>
<i class="fas fa-route text-orange-500 mb-1"></i>
<div class="text-sm font-semibold">{{ post.gpx_files.count() }}</div>
<div class="text-xs text-gray-500">Routes</div>
</div>
</div>
</div>
<!-- Action Buttons -->
<div class="space-y-2">
{% if post.published %}
<a href="{{ url_for('community.post_detail', id=post.id) }}"
class="block w-full text-center px-4 py-2 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition-all duration-200">
<i class="fas fa-eye mr-2"></i>View Live
</a>
{% endif %}
<div class="grid grid-cols-2 gap-2">
<a href="{{ url_for('community.edit_post', id=post.id) }}"
class="text-center px-3 py-2 bg-yellow-500 text-white font-semibold rounded-lg hover:bg-yellow-600 transition-all duration-200">
<i class="fas fa-edit mr-1"></i>Edit
</a>
<button type="button"
class="px-3 py-2 bg-red-500 text-white font-semibold rounded-lg hover:bg-red-600 transition-all duration-200"
data-post-id="{{ post.id }}"
data-post-title="{{ post.title }}"
onclick="confirmDelete(this)">
<i class="fas fa-trash mr-1"></i>Delete
</button>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<!-- No Adventures State -->
<div class="bg-white rounded-2xl shadow-xl p-12 text-center">
<i class="fas fa-mountain text-6xl text-gray-400 mb-6"></i>
<h3 class="text-2xl font-bold text-gray-900 mb-4">Your Adventure Journey Awaits!</h3>
<p class="text-gray-600 mb-8 max-w-md mx-auto">
You haven't shared any motorcycle adventures yet. Every great ride has a story worth telling!
</p>
<a href="{{ url_for('community.new_post') }}"
class="inline-flex items-center px-8 py-4 bg-gradient-to-r from-green-600 to-emerald-600 text-white font-semibold rounded-lg hover:from-green-700 hover:to-emerald-700 transition-all duration-200 transform hover:scale-105">
<i class="fas fa-plus mr-2"></i>Share Your First Adventure
</a>
<div class="mt-6">
<p class="text-sm text-gray-500">
<i class="fas fa-lightbulb mr-1"></i>
Share photos, GPS tracks, and stories of your rides to inspire the community
</p>
</div>
</div>
{% endif %}
<!-- Pagination -->
{% if posts.pages > 1 %}
<div class="flex justify-center mt-8">
<div class="bg-white rounded-lg shadow-lg px-6 py-4">
<div class="flex items-center space-x-4">
{% if posts.has_prev %}
<a href="{{ url_for('community.profile', page=posts.prev_num) }}"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200">
<i class="fas fa-chevron-left mr-1"></i>Previous
</a>
{% endif %}
<span class="text-gray-600">
Page {{ posts.page }} of {{ posts.pages }}
</span>
{% if posts.has_next %}
<a href="{{ url_for('community.profile', page=posts.next_num) }}"
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200">
Next<i class="fas fa-chevron-right ml-1"></i>
</a>
{% endif %}
</div>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="deleteModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
<div class="bg-white rounded-2xl p-8 max-w-md mx-4">
<div class="text-center">
<i class="fas fa-exclamation-triangle text-red-500 text-4xl mb-4"></i>
<h3 class="text-xl font-bold text-gray-900 mb-4">Delete Adventure</h3>
<p class="text-gray-600 mb-6">
Are you sure you want to delete "<span id="deletePostTitle" class="font-semibold"></span>"?
This action cannot be undone.
</p>
<div class="flex space-x-4">
<button onclick="closeDeleteModal()"
class="flex-1 px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400 transition-all duration-200">
Cancel
</button>
<form id="deleteForm" method="POST" class="flex-1">
<button type="submit"
class="w-full px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-all duration-200">
Delete
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
function confirmDelete(button) {
const postId = button.getAttribute('data-post-id');
const postTitle = button.getAttribute('data-post-title');
document.getElementById('deletePostTitle').textContent = postTitle;
document.getElementById('deleteForm').action = `/community/delete-post/${postId}`;
document.getElementById('deleteModal').classList.remove('hidden');
}
function closeDeleteModal() {
document.getElementById('deleteModal').classList.add('hidden');
}
// Close modal when clicking outside
document.getElementById('deleteModal').addEventListener('click', function(e) {
if (e.target === this) {
closeDeleteModal();
}
});
// Close modal on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
closeDeleteModal();
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,312 @@
{% extends "base.html" %}
{% block title %}My Profile - {{ current_user.nickname }}{% endblock %}
{% block content %}
<div class="container-fluid mt-4">
<!-- Header Section -->
<div class="row mb-4">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-8">
<h1 class="mb-3">
<i class="fas fa-user-circle text-primary"></i>
{{ current_user.nickname }}'s Profile
</h1>
<p class="text-muted mb-0">
<i class="fas fa-envelope"></i> {{ current_user.email }}
<i class="fas fa-calendar"></i> Member since {{ current_user.created_at.strftime('%B %Y') }}
</p>
</div>
<div class="col-md-4 text-md-end">
<a href="{{ url_for('community.new_post') }}" class="btn btn-primary btn-lg">
<i class="fas fa-plus"></i> Create New Adventure
</a>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Stats Section -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card text-center border-success">
<div class="card-body">
<i class="fas fa-check-circle fa-3x text-success mb-3"></i>
<h3 class="text-success">{{ published_count }}</h3>
<p class="text-muted mb-0">Published Adventures</p>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card text-center border-warning">
<div class="card-body">
<i class="fas fa-clock fa-3x text-warning mb-3"></i>
<h3 class="text-warning">{{ pending_count }}</h3>
<p class="text-muted mb-0">Pending Review</p>
</div>
</div>
</div>
</div>
<!-- Posts Section -->
<div class="row">
<div class="col-12">
<div class="card shadow-sm">
<div class="card-header bg-dark text-white">
<h4 class="mb-0">
<i class="fas fa-list"></i> My Adventures
</h4>
</div>
<div class="card-body">
{% if posts.items %}
<div class="row">
{% for post in posts.items %}
<div class="col-lg-6 col-xl-4 mb-4">
<div class="card h-100 {{ 'border-success' if post.published else 'border-warning' }}">
<!-- Cover Image -->
{% set cover_image = post.images | selectattr('is_cover', 'equalto', True) | first %}
{% if cover_image %}
<div class="position-relative">
<img src="{{ cover_image.get_thumbnail_url() }}"
alt="{{ post.title }}"
class="card-img-top"
style="height: 200px; object-fit: cover;">
<div class="position-absolute top-0 end-0 m-2">
<span class="badge {{ 'bg-success' if post.published else 'bg-warning text-dark' }}">
{{ 'Published' if post.published else 'Pending Review' }}
</span>
</div>
</div>
{% else %}
<div class="card-img-top bg-gradient-primary d-flex align-items-center justify-content-center text-white" style="height: 200px;">
<div class="text-center">
<i class="fas fa-mountain fa-3x mb-2"></i>
<div class="position-absolute top-0 end-0 m-2">
<span class="badge {{ 'bg-success' if post.published else 'bg-warning text-dark' }}">
{{ 'Published' if post.published else 'Pending Review' }}
</span>
</div>
</div>
</div>
{% endif %}
<div class="card-body d-flex flex-column">
<!-- Title and Subtitle -->
<h5 class="card-title">{{ post.title }}</h5>
{% if post.subtitle %}
<p class="card-text text-muted small">{{ post.subtitle }}</p>
{% endif %}
<!-- Difficulty -->
<div class="mb-2">
<span class="badge bg-warning text-dark">
{{ '⭐' * post.difficulty }}
{% if post.difficulty == 1 %}Easy{% elif post.difficulty == 2 %}Moderate{% elif post.difficulty == 3 %}Challenging{% elif post.difficulty == 4 %}Hard{% else %}Expert{% endif %}
</span>
</div>
<!-- Content Preview -->
<p class="card-text flex-grow-1">
{{ (post.content[:100] + '...') if post.content|length > 100 else post.content }}
</p>
<!-- Meta Information -->
<div class="text-muted small mb-3">
<div>
<i class="fas fa-calendar"></i>
Created: {{ post.created_at.strftime('%Y-%m-%d') }}
</div>
{% if post.updated_at and post.updated_at != post.created_at %}
<div>
<i class="fas fa-edit"></i>
Updated: {{ post.updated_at.strftime('%Y-%m-%d') }}
</div>
{% endif %}
<div>
<i class="fas fa-images"></i>
{{ post.images.count() }} images
<i class="fas fa-route"></i>
{{ post.gpx_files.count() }} GPS tracks
</div>
</div>
<!-- Action Buttons -->
<div class="d-grid gap-2">
<div class="btn-group" role="group">
{% if post.published %}
<a href="{{ url_for('community.post_detail', id=post.id) }}"
class="btn btn-outline-primary btn-sm">
<i class="fas fa-eye"></i> View
</a>
{% endif %}
<a href="{{ url_for('community.edit_post', id=post.id) }}"
class="btn btn-outline-warning btn-sm">
<i class="fas fa-edit"></i> Edit
</a>
<button type="button" class="btn btn-outline-danger btn-sm"
onclick="confirmDelete({{ post.id }}, '{{ post.title|replace("'", "\\'") }}')">
<i class="fas fa-trash"></i> Delete
</button>
</div>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
<!-- Pagination -->
{% if posts.pages > 1 %}
<nav aria-label="Posts pagination" class="mt-4">
<ul class="pagination justify-content-center">
{% if posts.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('community.profile', page=posts.prev_num) }}">
<i class="fas fa-chevron-left"></i> Previous
</a>
</li>
{% endif %}
{% for page_num in posts.iter_pages() %}
{% if page_num %}
{% if page_num != posts.page %}
<li class="page-item">
<a class="page-link" href="{{ url_for('community.profile', page=page_num) }}">{{ 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 posts.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('community.profile', page=posts.next_num) }}">
Next <i class="fas fa-chevron-right"></i>
</a>
</li>
{% endif %}
</ul>
</nav>
{% endif %}
{% else %}
<!-- No Posts State -->
<div class="text-center py-5">
<i class="fas fa-mountain fa-5x text-muted mb-4"></i>
<h3 class="text-muted">No Adventures Yet</h3>
<p class="text-muted mb-4">You haven't shared any motorcycle adventures yet. Start your journey!</p>
<a href="{{ url_for('community.new_post') }}" class="btn btn-primary btn-lg">
<i class="fas fa-plus"></i> Create Your First Adventure
</a>
</div>
{% endif %}
</div>
</div>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header bg-danger text-white">
<h5 class="modal-title" id="deleteModalLabel">
<i class="fas fa-exclamation-triangle"></i> Confirm Deletion
</h5>
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete the adventure:</p>
<h6 id="deletePostTitle" class="text-danger"></h6>
<p class="text-muted small mb-0">
<i class="fas fa-exclamation-circle"></i>
This action cannot be undone. All images and GPS tracks will also be deleted.
</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times"></i> Cancel
</button>
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
<i class="fas fa-trash"></i> Yes, Delete Adventure
</button>
</div>
</div>
</div>
</div>
<script>
let deletePostId = null;
function confirmDelete(postId, postTitle) {
deletePostId = postId;
document.getElementById('deletePostTitle').textContent = postTitle;
const modal = new bootstrap.Modal(document.getElementById('deleteModal'));
modal.show();
}
document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
if (deletePostId) {
const btn = this;
const originalText = btn.innerHTML;
// Show loading state
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Deleting...';
btn.disabled = true;
// Send delete request
fetch(`/community/delete-post/${deletePostId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json'
}
})
.then(response => response.json())
.then(data => {
if (data.success) {
// Hide modal
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteModal'));
modal.hide();
// Show success message and reload page
const alert = document.createElement('div');
alert.className = 'alert alert-success alert-dismissible fade show';
alert.innerHTML = `
<i class="fas fa-check-circle"></i> ${data.message}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
`;
document.querySelector('.container-fluid').insertBefore(alert, document.querySelector('.container-fluid').firstChild);
// Reload page after delay
setTimeout(() => {
window.location.reload();
}, 2000);
} else {
throw new Error(data.error || 'Failed to delete post');
}
})
.catch(error => {
console.error('Error:', error);
alert('An error occurred while deleting the post: ' + error.message);
})
.finally(() => {
// Restore button state
btn.innerHTML = originalText;
btn.disabled = false;
});
}
});
</script>
{% endblock %}

View File

@@ -0,0 +1,69 @@
{% extends "base.html" %}
{% block title %}Simple Post Creation Test{% endblock %}
{% block content %}
<div class="container mt-5">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h3>Simple Post Creation Test</h3>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('community.new_post') }}" enctype="multipart/form-data">
<div class="mb-3">
<label for="title" class="form-label">Title *</label>
<input type="text" class="form-control" id="title" name="title" required
value="{{ request.args.get('title', '') }}">
</div>
<div class="mb-3">
<label for="subtitle" class="form-label">Subtitle</label>
<input type="text" class="form-control" id="subtitle" name="subtitle"
value="{{ request.args.get('subtitle', '') }}">
</div>
<div class="mb-3">
<label for="difficulty" class="form-label">Difficulty *</label>
<select class="form-control" id="difficulty" name="difficulty" required>
<option value="">Select difficulty...</option>
<option value="1" {% if request.args.get('difficulty') == '1' %}selected{% endif %}>🟢 Easy</option>
<option value="2" {% if request.args.get('difficulty') == '2' %}selected{% endif %}>🟡 Moderate</option>
<option value="3" {% if request.args.get('difficulty') == '3' %}selected{% endif %}>🟠 Challenging</option>
<option value="4" {% if request.args.get('difficulty') == '4' %}selected{% endif %}>🔴 Difficult</option>
<option value="5" {% if request.args.get('difficulty') == '5' %}selected{% endif %}>🟣 Expert</option>
</select>
</div>
<div class="mb-3">
<label for="content" class="form-label">Content</label>
<textarea class="form-control" id="content" name="content" rows="4"
placeholder="Adventure details (optional for testing)">{{ request.args.get('content', 'Adventure details will be added later.') }}</textarea>
</div>
<div class="mb-3">
<label for="cover_picture" class="form-label">Cover Picture</label>
<input type="file" class="form-control" id="cover_picture" name="cover_picture" accept="image/*">
{% if request.args.get('cover_picture') %}
<small class="text-muted">Suggested: {{ request.args.get('cover_picture') }}</small>
{% endif %}
</div>
<div class="mb-3">
<label for="gpx_file" class="form-label">GPX File</label>
<input type="file" class="form-control" id="gpx_file" name="gpx_file" accept=".gpx">
{% if request.args.get('gpx_file') %}
<small class="text-muted">Suggested: {{ request.args.get('gpx_file') }}</small>
{% endif %}
</div>
<button type="submit" class="btn btn-primary">Create Post</button>
<a href="{{ url_for('community.index') }}" class="btn btn-secondary">Cancel</a>
</form>
</div>
</div>
</div>
</div>
</div>
{% endblock %}

62
run.py
View File

@@ -1,6 +1,6 @@
import os import os
from app import create_app, db from app import create_app, db
from app.models import User, Post, PostImage, GPXFile, Comment, Like from app.models import User, Post, PostImage, GPXFile, Comment, Like, PageView
app = create_app() app = create_app()
@@ -13,7 +13,8 @@ def make_shell_context():
'PostImage': PostImage, 'PostImage': PostImage,
'GPXFile': GPXFile, 'GPXFile': GPXFile,
'Comment': Comment, 'Comment': Comment,
'Like': Like 'Like': Like,
'PageView': PageView
} }
@app.cli.command() @app.cli.command()
@@ -22,6 +23,63 @@ def init_db():
db.create_all() db.create_all()
print('Database initialized.') print('Database initialized.')
@app.cli.command()
def migrate_db():
"""Apply database migrations."""
from sqlalchemy import text
try:
# Check if the media_folder column exists
result = db.session.execute(text('PRAGMA table_info(posts)'))
columns = [row[1] for row in result.fetchall()]
if 'media_folder' not in columns:
# Add the media_folder column
db.session.execute(text('ALTER TABLE posts ADD COLUMN media_folder VARCHAR(100)'))
db.session.commit()
print('Successfully added media_folder column to posts table')
else:
print('media_folder column already exists')
# Check if is_cover column exists in post_images table
result = db.session.execute(text('PRAGMA table_info(post_images)'))
columns = [row[1] for row in result.fetchall()]
if 'is_cover' not in columns:
# Add the is_cover column
db.session.execute(text('ALTER TABLE post_images ADD COLUMN is_cover BOOLEAN DEFAULT 0'))
db.session.commit()
print('Successfully added is_cover column to post_images table')
else:
print('is_cover column already exists')
# Check if page_views table exists
result = db.session.execute(text("SELECT name FROM sqlite_master WHERE type='table' AND name='page_views'"))
if not result.fetchone():
# Create page_views table
db.session.execute(text('''
CREATE TABLE page_views (
id INTEGER PRIMARY KEY AUTOINCREMENT,
path VARCHAR(255) NOT NULL,
user_agent VARCHAR(500),
ip_address VARCHAR(45),
referer VARCHAR(500),
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
user_id INTEGER,
post_id INTEGER,
FOREIGN KEY (user_id) REFERENCES users(id),
FOREIGN KEY (post_id) REFERENCES posts(id)
)
'''))
db.session.commit()
print('Successfully created page_views table')
else:
print('page_views table already exists')
print('Database schema is up to date')
except Exception as e:
print(f'Migration error: {e}')
db.session.rollback()
@app.cli.command() @app.cli.command()
def create_admin(): def create_admin():
"""Create an admin user.""" """Create an admin user."""