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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
|
||||
})
|
||||
@@ -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)
|
||||
|
||||
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 14 KiB |
@@ -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>
|
||||
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 14 KiB |
204
app/templates/admin/analytics.html
Normal 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 %}
|
||||
233
app/templates/admin/base.html
Normal 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>
|
||||
247
app/templates/admin/dashboard.html
Normal 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 %}
|
||||
235
app/templates/admin/post_detail.html
Normal 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 %}
|
||||
176
app/templates/admin/posts.html
Normal 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 %}
|
||||
185
app/templates/admin/user_detail.html
Normal 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 %}
|
||||
137
app/templates/admin/users.html
Normal 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 %}
|
||||
@@ -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
|
||||
|
||||
423
app/templates/community/edit_post.html
Normal 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 %}
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
521
app/templates/community/post_detail.html
Normal 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 %}
|
||||
941
app/templates/community/post_detail_fixed.html
Normal 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 %}
|
||||
521
app/templates/community/post_detail_new.html
Normal 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 %}
|
||||
297
app/templates/community/profile.html
Normal 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 %}
|
||||
312
app/templates/community/profile_new.html
Normal 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 %}
|
||||
69
app/templates/community/simple_form.html
Normal 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 %}
|
||||