- Removed all Node.js/Next.js dependencies and files - Cleaned up project structure to contain only Flask application - Updated .gitignore to exclude Python cache files, virtual environments, and development artifacts - Complete motorcycle adventure community website with: * Interactive Romania map with GPX route plotting * Advanced post creation with cover images, sections, highlights * User authentication and authorization system * Community features with likes and comments * Responsive design with blue-purple-teal gradient theme * Docker and production deployment configuration * SQLite database with proper models and relationships * Image and GPX file upload handling * Modern UI with improved form layouts and visual feedback Technical stack: - Flask 3.0.0 with SQLAlchemy, Flask-Login, Flask-Mail, Flask-WTF - Jinja2 templates with Tailwind CSS styling - Leaflet.js for interactive mapping - PostgreSQL/SQLite database support - Docker containerization with Nginx reverse proxy - Gunicorn WSGI server for production Project is now production-ready Flask application focused on motorcycle adventure sharing in Romania.
263 lines
9.7 KiB
Python
263 lines
9.7 KiB
Python
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify
|
|
from flask_login import login_required, current_user
|
|
from app.models import Post, PostImage, GPXFile, User, Comment, Like
|
|
from app.extensions import db
|
|
from app.forms import PostForm, CommentForm
|
|
from werkzeug.utils import secure_filename
|
|
from werkzeug.exceptions import RequestEntityTooLarge
|
|
import os
|
|
import uuid
|
|
from datetime import datetime
|
|
from PIL import Image
|
|
import gpxpy
|
|
|
|
community = Blueprint('community', __name__)
|
|
|
|
@community.route('/')
|
|
def index():
|
|
"""Community main page with map and posts"""
|
|
page = request.args.get('page', 1, type=int)
|
|
posts = Post.query.filter_by(published=True).order_by(Post.created_at.desc()).paginate(
|
|
page=page, per_page=12, error_out=False
|
|
)
|
|
|
|
# Get posts with GPX files for map display
|
|
posts_with_routes = Post.query.filter_by(published=True).join(GPXFile).all()
|
|
|
|
return render_template('community/index.html', posts=posts, posts_with_routes=posts_with_routes)
|
|
|
|
@community.route('/post/<int:id>')
|
|
def post_detail(id):
|
|
"""Individual post detail page"""
|
|
post = Post.query.get_or_404(id)
|
|
if not post.published and (not current_user.is_authenticated or
|
|
(current_user.id != post.author_id and not current_user.is_admin)):
|
|
flash('Post not found.', 'error')
|
|
return redirect(url_for('community.index'))
|
|
|
|
form = CommentForm()
|
|
comments = Comment.query.filter_by(post_id=id).order_by(Comment.created_at.asc()).all()
|
|
|
|
return render_template('community/post_detail.html', post=post, form=form, comments=comments)
|
|
|
|
@community.route('/new-post', methods=['GET', 'POST'])
|
|
@login_required
|
|
def new_post():
|
|
"""Create new post page"""
|
|
if request.method == 'POST':
|
|
try:
|
|
# 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)
|
|
|
|
# Validation
|
|
if not title:
|
|
return jsonify({'success': False, 'error': 'Title is required'})
|
|
if not difficulty or difficulty < 1 or difficulty > 5:
|
|
return jsonify({'success': False, 'error': 'Valid difficulty level is required'})
|
|
if not content:
|
|
return jsonify({'success': False, 'error': 'Content is required'})
|
|
|
|
# Create post
|
|
post = Post(
|
|
title=title,
|
|
subtitle=subtitle,
|
|
content=content,
|
|
difficulty=difficulty,
|
|
published=True, # Auto-publish for now
|
|
author_id=current_user.id
|
|
)
|
|
|
|
db.session.add(post)
|
|
db.session.flush() # Get the post ID
|
|
|
|
# Handle cover picture upload
|
|
if 'cover_picture' in request.files:
|
|
cover_file = request.files['cover_picture']
|
|
if cover_file and 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,
|
|
is_cover=True
|
|
)
|
|
db.session.add(cover_image)
|
|
|
|
# Handle GPX file upload
|
|
if 'gpx_file' in request.files:
|
|
gpx_file = request.files['gpx_file']
|
|
if gpx_file and 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)
|
|
|
|
db.session.commit()
|
|
|
|
return jsonify({
|
|
'success': True,
|
|
'message': 'Adventure shared successfully!',
|
|
'redirect_url': url_for('community.post_detail', id=post.id)
|
|
})
|
|
|
|
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'})
|
|
|
|
# GET request - show the form
|
|
return render_template('community/new_post.html')
|
|
|
|
@community.route('/post/<int:id>/comment', methods=['POST'])
|
|
@login_required
|
|
def add_comment(id):
|
|
"""Add comment to post"""
|
|
post = Post.query.get_or_404(id)
|
|
form = CommentForm()
|
|
|
|
if form.validate_on_submit():
|
|
comment = Comment(
|
|
content=form.content.data,
|
|
author_id=current_user.id,
|
|
post_id=post.id
|
|
)
|
|
db.session.add(comment)
|
|
db.session.commit()
|
|
flash('Your comment has been added.', 'success')
|
|
|
|
return redirect(url_for('community.post_detail', id=id))
|
|
|
|
@community.route('/post/<int:id>/like', methods=['POST'])
|
|
@login_required
|
|
def toggle_like(id):
|
|
"""Toggle like on post"""
|
|
post = Post.query.get_or_404(id)
|
|
existing_like = Like.query.filter_by(user_id=current_user.id, post_id=post.id).first()
|
|
|
|
if existing_like:
|
|
db.session.delete(existing_like)
|
|
liked = False
|
|
else:
|
|
like = Like(user_id=current_user.id, post_id=post.id)
|
|
db.session.add(like)
|
|
liked = True
|
|
|
|
db.session.commit()
|
|
return jsonify({'liked': liked, 'count': post.get_like_count()})
|
|
|
|
def save_image(image_file, post_id):
|
|
"""Save uploaded image file"""
|
|
try:
|
|
# Create upload directory
|
|
upload_dir = os.path.join(current_app.instance_path, 'uploads', 'images')
|
|
os.makedirs(upload_dir, exist_ok=True)
|
|
|
|
# Generate unique filename
|
|
filename = secure_filename(f"{uuid.uuid4().hex}_{image_file.filename}")
|
|
filepath = os.path.join(upload_dir, filename)
|
|
|
|
# Save and resize image
|
|
image = Image.open(image_file)
|
|
if image.mode in ('RGBA', 'LA', 'P'):
|
|
image = image.convert('RGB')
|
|
|
|
# Resize if too large
|
|
max_size = (1920, 1080)
|
|
image.thumbnail(max_size, Image.Resampling.LANCZOS)
|
|
image.save(filepath, 'JPEG', quality=85, optimize=True)
|
|
|
|
file_size = os.path.getsize(filepath)
|
|
|
|
return {
|
|
'success': True,
|
|
'filename': filename,
|
|
'size': file_size,
|
|
'mime_type': 'image/jpeg'
|
|
}
|
|
except Exception as e:
|
|
current_app.logger.error(f'Error saving image: {str(e)}')
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
def save_gpx_file(gpx_file, post_id):
|
|
"""Save uploaded GPX file"""
|
|
try:
|
|
# Create upload directory
|
|
upload_dir = os.path.join(current_app.instance_path, 'uploads', 'gpx')
|
|
os.makedirs(upload_dir, exist_ok=True)
|
|
|
|
# Generate unique filename
|
|
filename = secure_filename(f"{uuid.uuid4().hex}_{gpx_file.filename}")
|
|
filepath = os.path.join(upload_dir, filename)
|
|
|
|
# Validate GPX file
|
|
gpx_content = gpx_file.read()
|
|
gpx = gpxpy.parse(gpx_content.decode('utf-8'))
|
|
|
|
# Save file
|
|
with open(filepath, 'wb') as f:
|
|
f.write(gpx_content)
|
|
|
|
file_size = len(gpx_content)
|
|
|
|
return {
|
|
'success': True,
|
|
'filename': filename,
|
|
'size': file_size
|
|
}
|
|
except Exception as e:
|
|
current_app.logger.error(f'Error saving GPX file: {str(e)}')
|
|
return {
|
|
'success': False,
|
|
'error': str(e)
|
|
}
|
|
|
|
@community.route('/api/routes')
|
|
def api_routes():
|
|
"""API endpoint to get all routes for map display"""
|
|
posts_with_routes = Post.query.filter_by(published=True).join(GPXFile).all()
|
|
routes_data = []
|
|
|
|
for post in posts_with_routes:
|
|
for gpx_file in post.gpx_files:
|
|
try:
|
|
# Read and parse GPX file
|
|
gpx_path = os.path.join(current_app.instance_path, 'uploads', 'gpx', gpx_file.filename)
|
|
if os.path.exists(gpx_path):
|
|
with open(gpx_path, 'r') as f:
|
|
gpx_content = f.read()
|
|
|
|
gpx = gpxpy.parse(gpx_content)
|
|
|
|
# Extract coordinates
|
|
coordinates = []
|
|
for track in gpx.tracks:
|
|
for segment in track.segments:
|
|
for point in segment.points:
|
|
coordinates.append([point.latitude, point.longitude])
|
|
|
|
if coordinates:
|
|
routes_data.append({
|
|
'id': post.id,
|
|
'title': post.title,
|
|
'author': post.author.nickname,
|
|
'coordinates': coordinates,
|
|
'url': url_for('community.post_detail', id=post.id)
|
|
})
|
|
except Exception as e:
|
|
current_app.logger.error(f'Error processing GPX file {gpx_file.filename}: {str(e)}')
|
|
continue
|
|
|
|
return jsonify(routes_data)
|