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
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
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
from app.routes.main import main
@@ -38,6 +84,9 @@ def create_app(config_name=None):
from app.routes.community import community
app.register_blueprint(community, url_prefix='/community')
from app.routes.admin import admin
app.register_blueprint(admin, url_prefix='/admin')
# Create upload directories
upload_dir = os.path.join(app.instance_path, 'uploads')
os.makedirs(upload_dir, exist_ok=True)

View File

@@ -167,3 +167,24 @@ class Like(db.Model):
def __repr__(self):
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
import os
import uuid
import shutil
from datetime import datetime
from PIL import Image
import gpxpy
@@ -41,10 +42,35 @@ def post_detail(id):
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
def new_post():
"""Create new post page"""
def profile():
"""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':
try:
# Get form data
@@ -61,6 +87,150 @@ def new_post():
if not content:
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
post = Post(
title=title,
@@ -68,13 +238,17 @@ def new_post():
content=content,
difficulty=difficulty,
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
)
current_app.logger.info(f'Creating post: {post.title} by user {current_user.id}')
db.session.add(post)
db.session.flush() # Get the post ID
current_app.logger.info(f'Post created with ID: {post.id}')
# Create media folder for this post
media_folder_path = os.path.join(current_app.root_path, 'static', 'media', 'posts', post.media_folder)
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, '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:
cover_file = request.files['cover_picture']
if cover_file and cover_file.filename and MediaConfig.is_allowed_file(cover_file.filename, 'images'):
result = save_image(cover_file, post.id)
if result['success']:
# Save as 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)
if cover_file and cover_file.filename:
try:
current_app.logger.info(f'Processing cover picture: {cover_file.filename}')
result = save_image(cover_file, post.id)
if result['success']:
# Save as 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
# 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:
gpx_file = request.files['gpx_file']
if gpx_file and gpx_file.filename and MediaConfig.is_allowed_file(gpx_file.filename, 'gpx'):
result = save_gpx_file(gpx_file, post.id)
if result['success']:
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)
if gpx_file and gpx_file.filename:
try:
current_app.logger.info(f'Processing GPX file: {gpx_file.filename}')
result = save_gpx_file(gpx_file, post.id)
if result['success']:
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)
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()
current_app.logger.info(f'Post {post.id} committed to database successfully')
return jsonify({
'success': True,
'message': 'Adventure shared successfully!',
'redirect_url': url_for('community.post_detail', id=post.id)
'message': 'Adventure created successfully! It will be reviewed by admin before publishing.',
'redirect_url': url_for('community.index')
})
except Exception as e:
db.session.rollback()
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
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'])
@login_required
def add_comment(id):
@@ -417,8 +711,9 @@ def serve_thumbnail(post_folder, filename):
return serve_image(post_folder, filename)
@community.route('/media/posts/<post_folder>/gpx/<filename>')
@login_required
def serve_gpx(post_folder, filename):
"""Serve GPX files for download"""
"""Serve GPX files for download - requires login"""
try:
gpx_path = MediaConfig.get_media_path(current_app, post_folder, 'gpx')
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">
<i class="fas fa-plus mr-2"></i>Share Adventure
</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 %}
<a href="{{ url_for('admin.dashboard') }}" class="text-white hover:text-yellow-200 transition">
<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">
<i class="fas fa-plus mr-2"></i>New Post
</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 %}
<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

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">
<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>
{{ post.comments|length }}
{{ post.comments.count() }}
</span>
<span class="flex items-center">
<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>
</svg>
{{ post.likes|length }}
{{ post.likes.count() }}
</span>
</div>

View File

@@ -130,7 +130,7 @@
</div>
<!-- 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 -->
<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>
@@ -228,7 +228,11 @@
</div>
<!-- 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">
<i class="fas fa-paper-plane mr-3"></i>
Share Adventure
@@ -242,6 +246,38 @@
document.addEventListener('DOMContentLoaded', function() {
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
const coverUploadArea = document.querySelector('.cover-upload-area');
const coverInput = document.getElementById('cover_picture');
@@ -531,12 +567,8 @@ document.addEventListener('DOMContentLoaded', function() {
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');
if (savedSections.length === 0) {
alert('Please save at least one content section.');
return;
}
// Collect all form data
const formData = new FormData();
@@ -546,50 +578,245 @@ document.addEventListener('DOMContentLoaded', function() {
// Collect content from saved sections
let fullContent = '';
savedSections.forEach((section, index) => {
const highlights = Array.from(section.querySelectorAll('.saved-highlights .highlight-display'))
.map(span => span.textContent);
const text = section.querySelector('.saved-text').textContent;
if (highlights.length > 0) {
fullContent += highlights.map(h => `**${h}**`).join(' ') + '\n\n';
}
if (text) {
fullContent += text + '\n\n';
}
});
if (savedSections.length > 0) {
savedSections.forEach((section, index) => {
const highlights = Array.from(section.querySelectorAll('.saved-highlights .highlight-display'))
.map(span => span.textContent);
const text = section.querySelector('.saved-text').textContent;
if (highlights.length > 0) {
fullContent += highlights.map(h => `**${h}**`).join(' ') + '\n\n';
}
if (text) {
fullContent += text + '\n\n';
}
});
} else {
// If no sections saved, use a default message
fullContent = 'Adventure details will be added soon.';
}
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
const gpxFile = document.getElementById('gpx-file').files[0];
if (gpxFile) {
formData.append('gpx_file', gpxFile);
}
// Add images (simplified - in a real implementation, you'd handle this properly)
const allImages = document.querySelectorAll('.saved-images img');
// Note: This is a simplified version. In a real implementation,
// you'd need to properly handle the image files
// Show loading state
const submitBtn = document.querySelector('button[type="submit"]');
const originalText = submitBtn.innerHTML;
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-3"></i>Creating Adventure...';
submitBtn.disabled = true;
// Submit form
fetch('{{ url_for("community.new_post") }}', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
window.location.href = data.redirect_url;
.then(response => {
if (response.redirected) {
// Follow redirect
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 {
alert('Error creating post: ' + data.error);
throw new Error('Server error');
}
})
.catch(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>
{% 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 %}