Final cleanup: Complete Flask motorcycle adventure app
- 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.
This commit is contained in:
0
.env.example
Normal file
0
.env.example
Normal file
121
.gitignore
vendored
121
.gitignore
vendored
@@ -0,0 +1,121 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
*.so
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# pyenv
|
||||
.python-version
|
||||
|
||||
# celery beat schedule file
|
||||
celerybeat-schedule
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Application specific
|
||||
uploads/
|
||||
*.db
|
||||
*.sqlite
|
||||
@@ -1,16 +1,8 @@
|
||||
from flask import Flask
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_login import LoginManager
|
||||
from flask_mail import Mail
|
||||
from app.extensions import db, migrate, login_manager, mail
|
||||
from config import config
|
||||
import os
|
||||
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
login_manager = LoginManager()
|
||||
mail = Mail()
|
||||
|
||||
def create_app(config_name=None):
|
||||
app = Flask(__name__)
|
||||
|
||||
@@ -53,8 +45,3 @@ def create_app(config_name=None):
|
||||
os.makedirs(os.path.join(upload_dir, 'gpx'), exist_ok=True)
|
||||
|
||||
return app
|
||||
|
||||
@login_manager.user_loader
|
||||
def load_user(user_id):
|
||||
from app.models import User
|
||||
return User.query.get(int(user_id))
|
||||
|
||||
10
app/extensions.py
Normal file
10
app/extensions.py
Normal file
@@ -0,0 +1,10 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_migrate import Migrate
|
||||
from flask_login import LoginManager
|
||||
from flask_mail import Mail
|
||||
|
||||
# Initialize extensions
|
||||
db = SQLAlchemy()
|
||||
migrate = Migrate()
|
||||
login_manager = LoginManager()
|
||||
mail = Mail()
|
||||
@@ -1,9 +1,7 @@
|
||||
from datetime import datetime
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import UserMixin
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
db = SQLAlchemy()
|
||||
from app.extensions import db
|
||||
|
||||
class User(UserMixin, db.Model):
|
||||
__tablename__ = 'users'
|
||||
@@ -70,7 +68,8 @@ class PostImage(db.Model):
|
||||
original_name = db.Column(db.String(255), nullable=False)
|
||||
description = db.Column(db.Text)
|
||||
size = db.Column(db.Integer, nullable=False)
|
||||
mime_type = db.Column(db.String(100), nullable=False)
|
||||
mime_type = db.Column(db.String(100), default='image/jpeg', nullable=False)
|
||||
is_cover = db.Column(db.Boolean, default=False, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# Foreign Keys
|
||||
|
||||
@@ -61,8 +61,9 @@ def register():
|
||||
try:
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
flash('Registration successful! You can now log in.', 'success')
|
||||
return redirect(url_for('auth.login'))
|
||||
login_user(user)
|
||||
flash('Registration successful! Welcome to the community!', 'success')
|
||||
return redirect(url_for('community.index'))
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
flash('An error occurred during registration. Please try again.', 'error')
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
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, db
|
||||
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
|
||||
@@ -14,12 +15,16 @@ community = Blueprint('community', __name__)
|
||||
|
||||
@community.route('/')
|
||||
def index():
|
||||
"""Community posts listing page"""
|
||||
"""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=10, error_out=False
|
||||
page=page, per_page=12, error_out=False
|
||||
)
|
||||
return render_template('community/index.html', posts=posts)
|
||||
|
||||
# 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):
|
||||
@@ -39,61 +44,81 @@ def post_detail(id):
|
||||
@login_required
|
||||
def new_post():
|
||||
"""Create new post page"""
|
||||
form = PostForm()
|
||||
|
||||
if form.validate_on_submit():
|
||||
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=form.title.data,
|
||||
subtitle=form.subtitle.data,
|
||||
content=form.content.data,
|
||||
difficulty=int(form.difficulty.data),
|
||||
published=form.published.data,
|
||||
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 image uploads
|
||||
if form.images.data and form.images.data.filename:
|
||||
images = request.files.getlist('images')
|
||||
for image_file in images:
|
||||
if image_file and image_file.filename:
|
||||
result = save_image(image_file, 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']:
|
||||
post_image = PostImage(
|
||||
# Save as cover image
|
||||
cover_image = PostImage(
|
||||
filename=result['filename'],
|
||||
original_name=image_file.filename,
|
||||
original_name=cover_file.filename,
|
||||
size=result['size'],
|
||||
mime_type=image_file.content_type,
|
||||
post_id=post.id
|
||||
mime_type=result['mime_type'],
|
||||
post_id=post.id,
|
||||
is_cover=True
|
||||
)
|
||||
db.session.add(post_image)
|
||||
db.session.add(cover_image)
|
||||
|
||||
# Handle GPX file upload
|
||||
if form.gpx_file.data and form.gpx_file.data.filename:
|
||||
result = save_gpx_file(form.gpx_file.data, post.id)
|
||||
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 = GPXFile(
|
||||
gpx_file_record = GPXFile(
|
||||
filename=result['filename'],
|
||||
original_name=form.gpx_file.data.filename,
|
||||
original_name=gpx_file.filename,
|
||||
size=result['size'],
|
||||
post_id=post.id
|
||||
)
|
||||
db.session.add(gpx_file)
|
||||
db.session.add(gpx_file_record)
|
||||
|
||||
db.session.commit()
|
||||
flash('Your adventure has been shared!', 'success')
|
||||
return redirect(url_for('community.post_detail', id=post.id))
|
||||
|
||||
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()
|
||||
flash('An error occurred while creating your post. Please try again.', 'error')
|
||||
current_app.logger.error(f'Error creating post: {str(e)}')
|
||||
return jsonify({'success': False, 'error': 'An error occurred while creating your post'})
|
||||
|
||||
return render_template('community/new_post.html', form=form)
|
||||
# GET request - show the form
|
||||
return render_template('community/new_post.html')
|
||||
|
||||
@community.route('/post/<int:id>/comment', methods=['POST'])
|
||||
@login_required
|
||||
@@ -158,7 +183,8 @@ def save_image(image_file, post_id):
|
||||
return {
|
||||
'success': True,
|
||||
'filename': filename,
|
||||
'size': file_size
|
||||
'size': file_size,
|
||||
'mime_type': 'image/jpeg'
|
||||
}
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Error saving image: {str(e)}')
|
||||
@@ -192,4 +218,45 @@ def save_gpx_file(gpx_file, post_id):
|
||||
}
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Error saving GPX file: {str(e)}')
|
||||
return {'success': False, 'error': 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)
|
||||
|
||||
@@ -22,12 +22,12 @@
|
||||
</a>
|
||||
</div>
|
||||
<div class="hidden md:flex items-center space-x-8">
|
||||
<a href="{{ url_for('main.index') }}#about" class="text-white hover:text-blue-200 transition">About</a>
|
||||
<a href="{{ url_for('main.index') }}" class="text-white hover:text-blue-200 transition">Landing</a>
|
||||
<a href="{{ url_for('community.index') }}" class="text-white hover:text-teal-200 transition font-semibold">🏍️ Adventures</a>
|
||||
<a href="{{ url_for('main.index') }}#accommodation" class="text-white hover:text-purple-200 transition">Accommodation</a>
|
||||
<a href="{{ url_for('community.index') }}" class="text-white hover:text-teal-200 transition">Stories & Tracks</a>
|
||||
{% if current_user.is_authenticated %}
|
||||
<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>New Post
|
||||
<i class="fas fa-plus mr-2"></i>Share Adventure
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="text-white hover:text-yellow-200 transition">
|
||||
|
||||
328
app/templates/community/index.html
Normal file
328
app/templates/community/index.html
Normal file
@@ -0,0 +1,328 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Motorcycle Adventures Romania{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<style>
|
||||
.map-container {
|
||||
height: 500px;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
|
||||
}
|
||||
.route-popup {
|
||||
font-family: inherit;
|
||||
}
|
||||
.route-popup .popup-title {
|
||||
font-weight: 600;
|
||||
color: #1f2937;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.route-popup .popup-author {
|
||||
color: #6b7280;
|
||||
font-size: 0.875rem;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
.route-popup .popup-link {
|
||||
background: linear-gradient(135deg, #f97316, #dc2626);
|
||||
color: white;
|
||||
padding: 0.375rem 0.75rem;
|
||||
border-radius: 0.375rem;
|
||||
text-decoration: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
display: inline-block;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
.route-popup .popup-link:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-teal-900">
|
||||
<!-- Header Section -->
|
||||
<div class="relative overflow-hidden">
|
||||
<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 py-12">
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl md:text-5xl font-bold text-white mb-4">
|
||||
🏍️ Motorcycle Adventures Romania
|
||||
</h1>
|
||||
<p class="text-lg text-blue-100 max-w-3xl mx-auto">
|
||||
Discover epic motorcycle routes, share your adventures, and connect with fellow riders across Romania's stunning landscapes.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Interactive Map Section -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mb-12">
|
||||
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h2 class="text-2xl font-bold text-white">🗺️ Interactive Route Map</h2>
|
||||
<div class="text-sm text-blue-200">
|
||||
<span id="route-count">{{ posts_with_routes|length }}</span> routes discovered
|
||||
</div>
|
||||
</div>
|
||||
<div id="romania-map" class="map-container"></div>
|
||||
<p class="text-blue-200 text-sm mt-4 text-center">
|
||||
Click on any route to view the adventure story • Routes are updated live as new trips are shared
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 mb-8">
|
||||
<div class="flex flex-wrap justify-center gap-4">
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('community.new_post') }}"
|
||||
class="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-lg text-white bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 transform hover:scale-105 transition-all duration-200 shadow-lg">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
Share Your Adventure
|
||||
</a>
|
||||
<a href="{{ url_for('auth.logout') }}"
|
||||
class="inline-flex items-center px-6 py-3 border border-white/30 text-base font-medium rounded-lg text-white hover:bg-white/10 transition-all duration-200">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1"></path>
|
||||
</svg>
|
||||
Logout
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.register') }}"
|
||||
class="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-lg text-white bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 transform hover:scale-105 transition-all duration-200 shadow-lg">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M18 9v3m0 0v3m0-3h3m-3 0h-3m-2-5a4 4 0 11-8 0 4 4 0 018 0zM3 20a6 6 0 0112 0v1H3v-1z"></path>
|
||||
</svg>
|
||||
Join Community
|
||||
</a>
|
||||
<a href="{{ url_for('auth.login') }}"
|
||||
class="inline-flex items-center px-6 py-3 border border-white/30 text-base font-medium rounded-lg text-white hover:bg-white/10 transition-all duration-200">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M11 16l-4-4m0 0l4-4m-4 4h14m-5 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h7a3 3 0 013 3v1"></path>
|
||||
</svg>
|
||||
Login
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Latest Adventures Grid -->
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||||
<div class="flex justify-between items-center mb-8">
|
||||
<h2 class="text-3xl font-bold text-white">Latest Adventures</h2>
|
||||
<div class="text-blue-200 text-sm">
|
||||
Showing {{ posts.items|length }} of {{ posts.total }} stories
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if posts.items %}
|
||||
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{% for post in posts.items %}
|
||||
<article class="bg-white/10 backdrop-blur-sm rounded-xl overflow-hidden shadow-xl hover:shadow-2xl transition-all duration-300 border border-white/20 hover:border-white/40 transform hover:-translate-y-1">
|
||||
{% if post.images %}
|
||||
<div class="aspect-w-16 aspect-h-9 bg-gray-900">
|
||||
<img src="{{ url_for('static', filename='uploads/images/' + post.images[0].filename) }}"
|
||||
alt="{{ post.title }}"
|
||||
class="w-full h-48 object-cover">
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="p-5">
|
||||
<div class="flex items-center mb-3">
|
||||
<div class="flex-shrink-0">
|
||||
<div class="w-8 h-8 bg-gradient-to-r from-orange-500 to-red-600 rounded-full flex items-center justify-center">
|
||||
<span class="text-white font-bold text-xs">{{ post.author.nickname[0].upper() }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ml-2">
|
||||
<p class="text-sm font-medium text-white">{{ post.author.nickname }}</p>
|
||||
<p class="text-xs text-blue-200">{{ post.created_at.strftime('%b %d, %Y') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-lg font-bold text-white mb-2 line-clamp-2">
|
||||
<a href="{{ url_for('community.post_detail', id=post.id) }}"
|
||||
class="hover:text-orange-400 transition-colors">
|
||||
{{ post.title }}
|
||||
</a>
|
||||
</h3>
|
||||
|
||||
{% if post.subtitle %}
|
||||
<p class="text-sm text-blue-200 mb-2 line-clamp-1">{{ post.subtitle }}</p>
|
||||
{% endif %}
|
||||
|
||||
<p class="text-blue-100 text-sm mb-4 line-clamp-2">
|
||||
{{ post.content[:120] }}{% if post.content|length > 120 %}...{% endif %}
|
||||
</p>
|
||||
|
||||
<div class="flex items-center justify-between">
|
||||
<div class="flex items-center space-x-3 text-xs text-blue-200">
|
||||
{% if post.gpx_files %}
|
||||
<span class="flex items-center bg-green-500/20 px-2 py-1 rounded-full">
|
||||
<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="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"></path>
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 11a3 3 0 11-6 0 3 3 0 016 0z"></path>
|
||||
</svg>
|
||||
GPX
|
||||
</span>
|
||||
{% endif %}
|
||||
<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="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 }}
|
||||
</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 }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<a href="{{ url_for('community.post_detail', id=post.id) }}"
|
||||
class="text-orange-400 hover:text-orange-300 font-medium text-xs transition-colors">
|
||||
Read More →
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if posts.pages > 1 %}
|
||||
<div class="mt-12 flex justify-center">
|
||||
<nav class="flex items-center space-x-2">
|
||||
{% if posts.has_prev %}
|
||||
<a href="{{ url_for('community.index', page=posts.prev_num) }}"
|
||||
class="px-4 py-2 text-white bg-white/10 backdrop-blur-sm rounded-lg hover:bg-white/20 transition-colors">
|
||||
← Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in posts.iter_pages() %}
|
||||
{% if page_num %}
|
||||
{% if page_num != posts.page %}
|
||||
<a href="{{ url_for('community.index', page=page_num) }}"
|
||||
class="px-3 py-2 text-white bg-white/10 backdrop-blur-sm rounded-lg hover:bg-white/20 transition-colors">
|
||||
{{ page_num }}
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="px-3 py-2 text-white bg-orange-500 rounded-lg">{{ page_num }}</span>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<span class="px-3 py-2 text-blue-200">…</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if posts.has_next %}
|
||||
<a href="{{ url_for('community.index', page=posts.next_num) }}"
|
||||
class="px-4 py-2 text-white bg-white/10 backdrop-blur-sm rounded-lg hover:bg-white/20 transition-colors">
|
||||
Next →
|
||||
</a>
|
||||
{% endif %}
|
||||
</nav>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<!-- Empty State -->
|
||||
<div class="text-center py-16">
|
||||
<div class="max-w-md mx-auto">
|
||||
<svg class="w-16 h-16 text-blue-300 mx-auto mb-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 20H5a2 2 0 01-2-2V6a2 2 0 012-2h10a2 2 0 012 2v1m2 13a2 2 0 01-2-2V7m2 13a2 2 0 002-2V9.5a2 2 0 00-2-2h-2m-4-3H9M7 16h6M7 8h6v4H7V8z"></path>
|
||||
</svg>
|
||||
<h3 class="text-2xl font-bold text-white mb-4">No Adventures Yet</h3>
|
||||
<p class="text-blue-200 mb-6">
|
||||
Be the first to share your motorcycle adventure and map your route across Romania!
|
||||
</p>
|
||||
{% if current_user.is_authenticated %}
|
||||
<a href="{{ url_for('community.new_post') }}"
|
||||
class="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-lg text-white bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 transform hover:scale-105 transition-all duration-200">
|
||||
Share Your First Adventure
|
||||
</a>
|
||||
{% else %}
|
||||
<a href="{{ url_for('auth.register') }}"
|
||||
class="inline-flex items-center px-6 py-3 border border-transparent text-base font-medium rounded-lg text-white bg-gradient-to-r from-orange-500 to-red-600 hover:from-orange-600 hover:to-red-700 transform hover:scale-105 transition-all duration-200">
|
||||
Join to Share Adventures
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Initialize map centered on Romania
|
||||
var map = L.map('romania-map').setView([45.9432, 24.9668], 7);
|
||||
|
||||
// Add tile layer
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||
}).addTo(map);
|
||||
|
||||
// Custom route colors
|
||||
var routeColors = ['#f97316', '#dc2626', '#059669', '#7c3aed', '#db2777', '#2563eb'];
|
||||
var colorIndex = 0;
|
||||
|
||||
// Load and display routes
|
||||
fetch('{{ url_for("community.api_routes") }}')
|
||||
.then(response => response.json())
|
||||
.then(routes => {
|
||||
routes.forEach(route => {
|
||||
if (route.coordinates && route.coordinates.length > 0) {
|
||||
// Create polyline for the route
|
||||
var routeLine = L.polyline(route.coordinates, {
|
||||
color: routeColors[colorIndex % routeColors.length],
|
||||
weight: 4,
|
||||
opacity: 0.8
|
||||
}).addTo(map);
|
||||
|
||||
// Create popup content
|
||||
var popupContent = `
|
||||
<div class="route-popup">
|
||||
<div class="popup-title">${route.title}</div>
|
||||
<div class="popup-author">by ${route.author}</div>
|
||||
<a href="${route.url}" class="popup-link">View Adventure</a>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Add popup to route
|
||||
routeLine.bindPopup(popupContent);
|
||||
|
||||
// Add click event to highlight route
|
||||
routeLine.on('click', function(e) {
|
||||
this.setStyle({
|
||||
weight: 6,
|
||||
opacity: 1
|
||||
});
|
||||
setTimeout(() => {
|
||||
this.setStyle({
|
||||
weight: 4,
|
||||
opacity: 0.8
|
||||
});
|
||||
}, 2000);
|
||||
});
|
||||
|
||||
colorIndex++;
|
||||
}
|
||||
});
|
||||
|
||||
// Update route count
|
||||
document.getElementById('route-count').textContent = routes.length;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading routes:', error);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
595
app/templates/community/new_post.html
Normal file
595
app/templates/community/new_post.html
Normal file
@@ -0,0 +1,595 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Share Your Adventure - Create New Post{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
<style>
|
||||
.content-section {
|
||||
border: 2px dashed #d1d5db;
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1rem;
|
||||
transition: all 0.3s ease;
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
.content-section.editing {
|
||||
border-color: #3b82f6;
|
||||
background: rgba(59, 130, 246, 0.1);
|
||||
}
|
||||
|
||||
.content-section.saved {
|
||||
border-color: #10b981;
|
||||
border-style: solid;
|
||||
background: rgba(16, 185, 129, 0.1);
|
||||
}
|
||||
|
||||
.highlight-input {
|
||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
border: none;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
margin: 0 0.25rem;
|
||||
}
|
||||
|
||||
.highlight-display {
|
||||
background: linear-gradient(135deg, #fbbf24, #f59e0b);
|
||||
color: white;
|
||||
font-weight: 600;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 0.375rem;
|
||||
margin: 0 0.25rem;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.image-preview {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
margin: 0.5rem;
|
||||
}
|
||||
|
||||
.image-preview img {
|
||||
width: 150px;
|
||||
height: 100px;
|
||||
object-fit: cover;
|
||||
border-radius: 0.5rem;
|
||||
border: 2px solid #e5e7eb;
|
||||
}
|
||||
|
||||
.image-preview .remove-btn {
|
||||
position: absolute;
|
||||
top: -8px;
|
||||
right: -8px;
|
||||
background: #ef4444;
|
||||
color: white;
|
||||
border-radius: 50%;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: pointer;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
/* Cover upload styles */
|
||||
.cover-upload-area {
|
||||
transition: all 0.3s ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.cover-upload-area:hover {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
}
|
||||
|
||||
/* Dropdown styling */
|
||||
select option {
|
||||
background-color: #1f2937;
|
||||
color: #ffffff;
|
||||
}
|
||||
|
||||
select option:checked {
|
||||
background-color: #0891b2;
|
||||
}
|
||||
|
||||
/* Section styling */
|
||||
.content-section {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
backdrop-filter: blur(10px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
border-radius: 0.75rem;
|
||||
padding: 1.5rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.section-actions-frame {
|
||||
background: rgba(255, 255, 255, 0.05);
|
||||
border: 1px solid rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
.add-section-frame {
|
||||
background: linear-gradient(135deg, rgba(59, 130, 246, 0.1), rgba(147, 51, 234, 0.1));
|
||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-teal-900 py-12">
|
||||
<div class="max-w-4xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<!-- Header -->
|
||||
<div class="text-center mb-8">
|
||||
<h1 class="text-4xl font-bold text-white mb-4">
|
||||
🏍️ Share Your Adventure
|
||||
</h1>
|
||||
<p class="text-blue-200 text-lg">
|
||||
Create a detailed story of your motorcycle journey through Romania
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Main Form -->
|
||||
<form id="postForm" 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>
|
||||
|
||||
<!-- Cover Picture -->
|
||||
<div class="mb-6">
|
||||
<label for="cover_picture" class="block text-white font-semibold mb-2">Set Cover Picture for the Post</label>
|
||||
<div class="cover-upload-area border-2 border-dashed border-white/30 rounded-lg p-6 text-center hover:border-white/50 transition-all duration-300">
|
||||
<input type="file" id="cover_picture" name="cover_picture" accept="image/*" class="hidden">
|
||||
<div class="cover-upload-content">
|
||||
<div class="text-4xl mb-2">📸</div>
|
||||
<p class="text-white/80 mb-2">Click to upload cover image</p>
|
||||
<p class="text-sm text-white/60">Recommended: 1920x1080 pixels</p>
|
||||
</div>
|
||||
<div class="cover-preview hidden">
|
||||
<img class="cover-preview-image max-h-48 mx-auto rounded-lg" alt="Cover preview">
|
||||
<button type="button" class="cover-remove-btn mt-2 px-3 py-1 bg-red-500/80 text-white rounded hover:bg-red-600 transition-colors">Remove</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Title -->
|
||||
<div class="mb-6">
|
||||
<label for="title" class="block text-white font-semibold mb-2">Adventure Title *</label>
|
||||
<input type="text" id="title" name="title" required
|
||||
class="w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur-sm border border-white/20
|
||||
text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-cyan-400
|
||||
focus:border-transparent transition-all duration-300"
|
||||
placeholder="Give your adventure a captivating title...">
|
||||
</div>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<div class="mb-6">
|
||||
<label for="subtitle" class="block text-white font-semibold mb-2">Subtitle</label>
|
||||
<input type="text" id="subtitle" name="subtitle"
|
||||
class="w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur-sm border border-white/20
|
||||
text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-cyan-400
|
||||
focus:border-transparent transition-all duration-300"
|
||||
placeholder="A brief description of your adventure">
|
||||
</div>
|
||||
|
||||
<!-- Difficulty Rating -->
|
||||
<div class="mb-6">
|
||||
<label for="difficulty" class="block text-white font-semibold mb-2">Route Difficulty *</label>
|
||||
<select id="difficulty" name="difficulty" required
|
||||
class="w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur-sm border border-white/20
|
||||
text-white focus:outline-none focus:ring-2 focus:ring-cyan-400
|
||||
focus:border-transparent transition-all duration-300">
|
||||
<option value="" class="bg-gray-800 text-gray-300">Select difficulty level...</option>
|
||||
<option value="1" class="bg-gray-800 text-green-400">🟢 Easy - Beginner friendly roads</option>
|
||||
<option value="2" class="bg-gray-800 text-yellow-400">🟡 Moderate - Some experience needed</option>
|
||||
<option value="3" class="bg-gray-800 text-orange-400">🟠 Challenging - Experienced riders</option>
|
||||
<option value="4" class="bg-gray-800 text-red-400">🔴 Difficult - Advanced skills required</option>
|
||||
<option value="5" class="bg-gray-800 text-purple-400">🟣 Expert - Only for experts</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Sections -->
|
||||
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
|
||||
<h2 class="text-2xl font-bold text-white mb-6">📖 Adventure Story</h2>
|
||||
<div id="content-sections">
|
||||
<!-- Initial content section will be added by JavaScript -->
|
||||
</div>
|
||||
|
||||
<!-- Add New Section Frame -->
|
||||
<div class="add-section-frame bg-gradient-to-r from-blue-500/10 to-purple-500/10 border border-blue-400/30 rounded-lg p-6 text-center mt-6">
|
||||
<div class="mb-4">
|
||||
<h3 class="text-lg font-semibold text-blue-300 mb-2">✨ Expand Your Story</h3>
|
||||
<p class="text-white/80 text-sm">Add another section with new text, pictures, and highlights to make your adventure more detailed and engaging</p>
|
||||
</div>
|
||||
<button type="button" id="add-section-btn" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-3 rounded-lg transition-all duration-300 transform hover:scale-105 inline-flex items-center">
|
||||
<i class="fas fa-plus mr-2"></i>
|
||||
Add New Section
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- GPX Upload -->
|
||||
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
|
||||
<h2 class="text-2xl font-bold text-white mb-6">🗺️ Route File</h2>
|
||||
<div class="border-2 border-dashed border-white/30 rounded-lg p-8 text-center">
|
||||
<i class="fas fa-route text-4xl text-blue-300 mb-4"></i>
|
||||
<div class="text-white font-semibold mb-2">Upload GPX Route File</div>
|
||||
<div class="text-blue-200 text-sm mb-4">
|
||||
Share your exact route so others can follow your adventure
|
||||
</div>
|
||||
<input type="file" id="gpx-file" name="gpx_file" accept=".gpx" class="hidden">
|
||||
<label for="gpx-file" class="inline-flex items-center px-6 py-3 bg-blue-600 text-white rounded-lg hover:bg-blue-700 cursor-pointer transition">
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
Choose GPX File
|
||||
</label>
|
||||
<div id="gpx-filename" class="mt-4 text-green-300 hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="text-center">
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let sectionCounter = 0;
|
||||
|
||||
// Cover picture upload
|
||||
const coverUploadArea = document.querySelector('.cover-upload-area');
|
||||
const coverInput = document.getElementById('cover_picture');
|
||||
const coverContent = document.querySelector('.cover-upload-content');
|
||||
const coverPreview = document.querySelector('.cover-preview');
|
||||
const coverImage = document.querySelector('.cover-preview-image');
|
||||
const coverRemoveBtn = document.querySelector('.cover-remove-btn');
|
||||
|
||||
coverUploadArea.addEventListener('click', () => coverInput.click());
|
||||
|
||||
coverInput.addEventListener('change', function() {
|
||||
const file = this.files[0];
|
||||
if (file) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
coverImage.src = e.target.result;
|
||||
coverContent.style.display = 'none';
|
||||
coverPreview.classList.remove('hidden');
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
|
||||
coverRemoveBtn.addEventListener('click', function(e) {
|
||||
e.stopPropagation();
|
||||
coverInput.value = '';
|
||||
coverContent.style.display = 'block';
|
||||
coverPreview.classList.add('hidden');
|
||||
});
|
||||
|
||||
// GPX file input
|
||||
document.getElementById('gpx-file').addEventListener('change', function() {
|
||||
const filename = this.files[0]?.name;
|
||||
const filenameDiv = document.getElementById('gpx-filename');
|
||||
if (filename) {
|
||||
filenameDiv.textContent = `Selected: ${filename}`;
|
||||
filenameDiv.classList.remove('hidden');
|
||||
} else {
|
||||
filenameDiv.classList.add('hidden');
|
||||
}
|
||||
});
|
||||
|
||||
// Add initial content section
|
||||
addContentSection();
|
||||
|
||||
// Add section button
|
||||
document.getElementById('add-section-btn').addEventListener('click', addContentSection);
|
||||
|
||||
function addContentSection() {
|
||||
sectionCounter++;
|
||||
const sectionsContainer = document.getElementById('content-sections');
|
||||
|
||||
const section = document.createElement('div');
|
||||
section.className = 'content-section editing';
|
||||
section.dataset.sectionId = sectionCounter;
|
||||
|
||||
section.innerHTML = `
|
||||
<div class="section-header mb-4">
|
||||
<h3 class="text-lg font-semibold text-white">Section ${sectionCounter}</h3>
|
||||
</div>
|
||||
|
||||
<div class="section-content">
|
||||
<!-- Highlights Input -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-white font-semibold mb-2">Key Highlights (1-5 words)</label>
|
||||
<div class="highlights-container mb-2">
|
||||
<input type="text" class="highlight-input bg-white/10 backdrop-blur-sm border border-white/20 text-white placeholder-white/60 px-3 py-2 rounded mr-2" placeholder="Highlight 1" maxlength="20">
|
||||
<input type="text" class="highlight-input bg-white/10 backdrop-blur-sm border border-white/20 text-white placeholder-white/60 px-3 py-2 rounded mr-2" placeholder="Highlight 2" maxlength="20">
|
||||
<input type="text" class="highlight-input bg-white/10 backdrop-blur-sm border border-white/20 text-white placeholder-white/60 px-3 py-2 rounded mr-2" placeholder="Highlight 3" maxlength="20">
|
||||
<button type="button" class="add-highlight-btn text-white bg-blue-600 px-3 py-1 rounded ml-2 hover:bg-blue-700 transition">
|
||||
<i class="fas fa-plus"></i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Text Content -->
|
||||
<div class="mb-4">
|
||||
<label class="block text-white font-semibold mb-2">Section Content</label>
|
||||
<textarea class="section-text w-full px-4 py-3 rounded-lg bg-white/10 backdrop-blur-sm border border-white/20 text-white placeholder-white/60 focus:outline-none focus:ring-2 focus:ring-cyan-400 focus:border-transparent"
|
||||
rows="6" placeholder="Describe this part of your adventure..."></textarea>
|
||||
</div>
|
||||
|
||||
<!-- Image Upload -->
|
||||
<div class="mb-6">
|
||||
<label class="block text-white font-semibold mb-2">Photos</label>
|
||||
<div class="image-upload-area border-2 border-dashed border-white/30 rounded-lg p-4 text-center hover:border-white/50 transition-all duration-300">
|
||||
<input type="file" class="section-images" multiple accept="image/*" style="display: none;">
|
||||
<button type="button" class="upload-images-btn bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition">
|
||||
<i class="fas fa-camera mr-2"></i> Add Photos
|
||||
</button>
|
||||
<div class="images-preview mt-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Section Actions Frame -->
|
||||
<div class="section-actions-frame bg-white/5 border border-white/20 rounded-lg p-4 mb-4">
|
||||
<div class="text-center mb-3">
|
||||
<p class="text-yellow-300 font-medium">💡 Section Actions</p>
|
||||
<p class="text-white/70 text-sm">Save your content, delete this section, or edit after saving</p>
|
||||
</div>
|
||||
<div class="section-actions flex justify-center space-x-3">
|
||||
<button type="button" class="save-section-btn bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 transition">
|
||||
<i class="fas fa-save mr-1"></i> Save
|
||||
</button>
|
||||
<button type="button" class="delete-section-btn bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 transition">
|
||||
<i class="fas fa-trash mr-1"></i> Delete
|
||||
</button>
|
||||
<button type="button" class="edit-section-btn bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition hidden">
|
||||
<i class="fas fa-edit mr-1"></i> Edit
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Saved Content Display (hidden initially) -->
|
||||
<div class="saved-content hidden">
|
||||
<div class="saved-highlights mb-4"></div>
|
||||
<div class="saved-text mb-4 text-white"></div>
|
||||
<div class="saved-images"></div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
sectionsContainer.appendChild(section);
|
||||
|
||||
// Add event listeners for this section
|
||||
setupSectionEventListeners(section);
|
||||
}
|
||||
|
||||
function setupSectionEventListeners(section) {
|
||||
const sectionId = section.dataset.sectionId;
|
||||
|
||||
// Image upload
|
||||
const uploadBtn = section.querySelector('.upload-images-btn');
|
||||
const imageInput = section.querySelector('.section-images');
|
||||
const imagesPreview = section.querySelector('.images-preview');
|
||||
|
||||
uploadBtn.addEventListener('click', () => imageInput.click());
|
||||
|
||||
imageInput.addEventListener('change', function() {
|
||||
const files = Array.from(this.files);
|
||||
files.forEach(file => {
|
||||
if (file.type.startsWith('image/')) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
const imagePreview = document.createElement('div');
|
||||
imagePreview.className = 'image-preview';
|
||||
imagePreview.innerHTML = `
|
||||
<img src="${e.target.result}" alt="Preview">
|
||||
<div class="remove-btn">×</div>
|
||||
`;
|
||||
|
||||
imagePreview.querySelector('.remove-btn').addEventListener('click', function() {
|
||||
imagePreview.remove();
|
||||
});
|
||||
|
||||
imagesPreview.appendChild(imagePreview);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
// Add highlight button
|
||||
section.querySelector('.add-highlight-btn').addEventListener('click', function() {
|
||||
const highlightsContainer = section.querySelector('.highlights-container');
|
||||
const newHighlight = document.createElement('input');
|
||||
newHighlight.type = 'text';
|
||||
newHighlight.className = 'highlight-input';
|
||||
newHighlight.placeholder = 'New highlight';
|
||||
newHighlight.maxLength = 20;
|
||||
highlightsContainer.insertBefore(newHighlight, this);
|
||||
});
|
||||
|
||||
// Save section
|
||||
section.querySelector('.save-section-btn').addEventListener('click', function() {
|
||||
saveSection(section);
|
||||
});
|
||||
|
||||
// Delete section
|
||||
section.querySelector('.delete-section-btn').addEventListener('click', function() {
|
||||
if (confirm('Are you sure you want to delete this section?')) {
|
||||
section.remove();
|
||||
}
|
||||
});
|
||||
|
||||
// Edit section
|
||||
section.querySelector('.edit-section-btn').addEventListener('click', function() {
|
||||
editSection(section);
|
||||
});
|
||||
}
|
||||
|
||||
function saveSection(section) {
|
||||
const highlights = Array.from(section.querySelectorAll('.highlight-input'))
|
||||
.map(input => input.value.trim())
|
||||
.filter(value => value);
|
||||
|
||||
const text = section.querySelector('.section-text').value.trim();
|
||||
const images = section.querySelectorAll('.images-preview img');
|
||||
|
||||
if (!text && highlights.length === 0 && images.length === 0) {
|
||||
alert('Please add some content to this section before saving.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Show saved content
|
||||
const savedContent = section.querySelector('.saved-content');
|
||||
const savedHighlights = section.querySelector('.saved-highlights');
|
||||
const savedText = section.querySelector('.saved-text');
|
||||
const savedImages = section.querySelector('.saved-images');
|
||||
|
||||
// Display highlights
|
||||
savedHighlights.innerHTML = highlights.map(h =>
|
||||
`<span class="highlight-display bg-cyan-600/20 text-cyan-300 px-2 py-1 rounded mr-2">${h}</span>`
|
||||
).join('');
|
||||
|
||||
// Display text
|
||||
savedText.innerHTML = text.replace(/\n/g, '<br>');
|
||||
|
||||
// Display images
|
||||
savedImages.innerHTML = '';
|
||||
images.forEach(img => {
|
||||
const imgCopy = img.cloneNode();
|
||||
imgCopy.className = 'w-32 h-24 object-cover rounded m-1';
|
||||
savedImages.appendChild(imgCopy);
|
||||
});
|
||||
|
||||
// Hide editing interface and show saved content
|
||||
section.querySelector('.section-content').style.display = 'none';
|
||||
savedContent.classList.remove('hidden');
|
||||
|
||||
// Update button visibility in actions frame
|
||||
section.querySelector('.save-section-btn').style.display = 'none';
|
||||
section.querySelector('.delete-section-btn').style.display = 'none';
|
||||
section.querySelector('.edit-section-btn').style.display = 'inline-flex';
|
||||
|
||||
// Update section appearance
|
||||
section.classList.remove('editing');
|
||||
section.classList.add('saved');
|
||||
|
||||
// Update header
|
||||
section.querySelector('.section-header h3').textContent = `Section ${section.dataset.sectionId} ✓`;
|
||||
|
||||
// Update actions frame text
|
||||
const actionsFrame = section.querySelector('.section-actions-frame');
|
||||
actionsFrame.querySelector('p:first-child').innerHTML = '✅ <span class="text-green-300">Section Saved</span>';
|
||||
actionsFrame.querySelector('p:last-child').textContent = 'Your content has been saved. You can edit it again if needed.';
|
||||
}
|
||||
|
||||
function editSection(section) {
|
||||
// Show editing interface and hide saved content
|
||||
section.querySelector('.section-content').style.display = 'block';
|
||||
section.querySelector('.saved-content').classList.add('hidden');
|
||||
|
||||
// Update button visibility in actions frame
|
||||
section.querySelector('.save-section-btn').style.display = 'inline-flex';
|
||||
section.querySelector('.delete-section-btn').style.display = 'inline-flex';
|
||||
section.querySelector('.edit-section-btn').style.display = 'none';
|
||||
|
||||
// Update section appearance
|
||||
section.classList.remove('saved');
|
||||
section.classList.add('editing');
|
||||
|
||||
// Update header
|
||||
section.querySelector('.section-header h3').textContent = `Section ${section.dataset.sectionId}`;
|
||||
|
||||
// Restore actions frame text
|
||||
const actionsFrame = section.querySelector('.section-actions-frame');
|
||||
actionsFrame.querySelector('p:first-child').innerHTML = '💡 <span class="text-yellow-300">Section Actions</span>';
|
||||
actionsFrame.querySelector('p:last-child').textContent = 'Save your content, delete this section, or edit after saving';
|
||||
}
|
||||
|
||||
// Form submission
|
||||
document.getElementById('adventure-form').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Validate required fields
|
||||
const title = document.getElementById('title').value.trim();
|
||||
const difficulty = document.getElementById('difficulty').value;
|
||||
|
||||
if (!title) {
|
||||
alert('Please enter an adventure title.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!difficulty) {
|
||||
alert('Please select a difficulty level.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if at least one section is saved
|
||||
const savedSections = document.querySelectorAll('.content-section.saved');
|
||||
if (savedSections.length === 0) {
|
||||
alert('Please save at least one content section.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect all form data
|
||||
const formData = new FormData();
|
||||
formData.append('title', title);
|
||||
formData.append('subtitle', document.getElementById('subtitle').value.trim());
|
||||
formData.append('difficulty', difficulty);
|
||||
|
||||
// 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';
|
||||
}
|
||||
});
|
||||
|
||||
formData.append('content', fullContent);
|
||||
|
||||
// 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
|
||||
|
||||
// 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;
|
||||
} else {
|
||||
alert('Error creating post: ' + data.error);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while creating your post.');
|
||||
});
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -18,11 +18,11 @@
|
||||
and find the perfect accommodation for your next adventure.
|
||||
</p>
|
||||
<div class="flex flex-col sm:flex-row gap-4 justify-center">
|
||||
<a href="#about" class="bg-white text-blue-600 px-8 py-4 rounded-lg font-semibold text-lg hover:bg-gray-100 transition transform hover:scale-105 shadow-lg">
|
||||
<i class="fas fa-compass mr-2"></i>Start Exploring
|
||||
<a href="{{ url_for('community.index') }}" class="bg-white text-blue-600 px-8 py-4 rounded-lg font-semibold text-lg hover:bg-gray-100 transition transform hover:scale-105 shadow-lg">
|
||||
<i class="fas fa-map-marked-alt mr-2"></i>Explore Adventures
|
||||
</a>
|
||||
<a href="{{ url_for('community.index') }}" class="border-2 border-white text-white px-8 py-4 rounded-lg font-semibold text-lg hover:bg-white hover:text-purple-600 transition transform hover:scale-105">
|
||||
<i class="fas fa-users mr-2"></i>Join Community
|
||||
<a href="#accommodation" class="border-2 border-white text-white px-8 py-4 rounded-lg font-semibold text-lg hover:bg-white hover:text-purple-600 transition transform hover:scale-105">
|
||||
<i class="fas fa-bed mr-2"></i>Find Accommodation
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
@@ -40,6 +40,12 @@
|
||||
Join our passionate community of motorcycle enthusiasts as we explore the most spectacular
|
||||
routes Romania has to offer. From winding mountain passes to scenic coastal roads.
|
||||
</p>
|
||||
<div class="mt-8">
|
||||
<a href="{{ url_for('community.index') }}" class="inline-flex items-center px-8 py-4 bg-gradient-to-r from-blue-600 to-purple-600 text-white font-semibold rounded-lg hover:from-blue-700 hover:to-purple-700 transform hover:scale-105 transition-all duration-200 shadow-lg">
|
||||
<i class="fas fa-map-marked-alt mr-3"></i>
|
||||
Explore Interactive Route Map
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-12 items-center">
|
||||
|
||||
@@ -5,7 +5,7 @@ load_dotenv()
|
||||
|
||||
class Config:
|
||||
SECRET_KEY = os.environ.get('SECRET_KEY') or 'dev-secret-key-change-in-production'
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'postgresql://postgres:password@localhost/moto_adventure'
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('DATABASE_URL') or 'sqlite:///moto_adventure.db'
|
||||
SQLALCHEMY_TRACK_MODIFICATIONS = False
|
||||
|
||||
# File Upload Configuration
|
||||
@@ -31,7 +31,7 @@ class Config:
|
||||
|
||||
class DevelopmentConfig(Config):
|
||||
DEBUG = True
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or 'postgresql://postgres:password@localhost/moto_adventure_dev'
|
||||
SQLALCHEMY_DATABASE_URI = os.environ.get('DEV_DATABASE_URL') or 'sqlite:///moto_adventure_dev.db'
|
||||
|
||||
class ProductionConfig(Config):
|
||||
DEBUG = False
|
||||
|
||||
Reference in New Issue
Block a user