Fix: Clean up map_iframe_single.html, remove debug overlay, ensure clean map rendering.

This commit is contained in:
ske087
2025-07-26 15:19:52 +03:00
parent 187254beca
commit 5ddde4bd9b
23 changed files with 1755 additions and 1658 deletions

View File

@@ -175,6 +175,83 @@ class Like(db.Model):
def __repr__(self):
return f'<Like {self.user_id}-{self.post_id}>'
class MapRoute(db.Model):
__tablename__ = 'map_routes'
id = db.Column(db.Integer, primary_key=True)
# Route Data (stored as JSON for efficient loading)
coordinates = db.Column(db.Text, nullable=False) # JSON array of [lat, lng] points
simplified_coordinates = db.Column(db.Text) # Simplified version for map overview
# Route Bounds
start_latitude = db.Column(db.Float, nullable=False)
start_longitude = db.Column(db.Float, nullable=False)
end_latitude = db.Column(db.Float, nullable=False)
end_longitude = db.Column(db.Float, nullable=False)
# Bounding Box for map fitting
bounds_north = db.Column(db.Float, nullable=False)
bounds_south = db.Column(db.Float, nullable=False)
bounds_east = db.Column(db.Float, nullable=False)
bounds_west = db.Column(db.Float, nullable=False)
# Route Statistics (copied from GPX processing)
total_distance = db.Column(db.Float, default=0.0) # in kilometers
elevation_gain = db.Column(db.Float, default=0.0) # in meters
max_elevation = db.Column(db.Float, default=0.0) # in meters
min_elevation = db.Column(db.Float, default=0.0) # in meters
total_points = db.Column(db.Integer, default=0) # original number of points
simplified_points = db.Column(db.Integer, default=0) # simplified points count
# Metadata
created_at = db.Column(db.DateTime, default=datetime.utcnow)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
# Foreign Keys
post_id = db.Column(db.Integer, db.ForeignKey('posts.id'), nullable=False, unique=True)
gpx_file_id = db.Column(db.Integer, db.ForeignKey('gpx_files.id'), nullable=False)
# Relationships
post = db.relationship('Post', backref=db.backref('map_route', uselist=False))
gpx_file = db.relationship('GPXFile', backref='map_route')
def get_coordinates_json(self):
"""Get coordinates as parsed JSON"""
import json
return json.loads(self.coordinates) if self.coordinates else []
def get_simplified_coordinates_json(self):
"""Get simplified coordinates as parsed JSON"""
import json
return json.loads(self.simplified_coordinates) if self.simplified_coordinates else []
def get_bounds(self):
"""Get bounding box as dict"""
return {
'north': self.bounds_north,
'south': self.bounds_south,
'east': self.bounds_east,
'west': self.bounds_west
}
def get_start_point(self):
"""Get start point as dict"""
return {
'latitude': self.start_latitude,
'longitude': self.start_longitude
}
def get_end_point(self):
"""Get end point as dict"""
return {
'latitude': self.end_latitude,
'longitude': self.end_longitude
}
def __repr__(self):
return f'<MapRoute for Post {self.post_id}>'
class PageView(db.Model):
__tablename__ = 'page_views'

View File

@@ -126,26 +126,50 @@ def post_detail(post_id):
@login_required
@admin_required
def publish_post(post_id):
"""Publish a post"""
"""Publish a post and create map routes if GPX files exist"""
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')
try:
db.session.commit()
# Create map routes for GPX files when post is published
from app.utils.gpx_processor import process_post_approval
success = process_post_approval(post_id)
if success:
flash(f'Post "{post.title}" has been published and map routes created.', 'success')
else:
flash(f'Post "{post.title}" has been published. No GPX files found or error creating map routes.', 'warning')
except Exception as e:
db.session.rollback()
current_app.logger.error(f'Error publishing post {post_id}: {str(e)}')
flash(f'Error publishing post: {str(e)}', 'error')
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"""
"""Unpublish a post and remove from map"""
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')
try:
# Note: We keep the MapRoute data for potential re-publishing
# Only the API will filter by published status
db.session.commit()
flash(f'Post "{post.title}" has been unpublished.', 'success')
except Exception as e:
db.session.rollback()
current_app.logger.error(f'Error unpublishing post {post_id}: {str(e)}')
flash(f'Error unpublishing post: {str(e)}', 'error')
return redirect(url_for('admin.posts'))
@admin.route('/posts/<int:post_id>/delete', methods=['POST'])
@@ -168,19 +192,23 @@ def delete_post(post_id):
# Delete associated files and records
db.session.delete(post)
db.session.commit()
# Clean up orphaned media folders
from app.utils.clean_orphan_media import clean_orphan_post_media
clean_orphan_post_media()
# Check posts after deletion
all_posts_after = Post.query.all()
current_app.logger.info(f'Posts after deletion: {[p.id for p in all_posts_after]}')
current_app.logger.info(f'Successfully deleted post {post_id}: "{title}"')
flash(f'Post "{title}" has been deleted.', 'success')
except Exception as e:
db.session.rollback()
current_app.logger.error(f'Error deleting post {post_id}: {str(e)}')
flash(f'Error deleting post: {str(e)}', 'error')
return redirect(url_for('admin.posts'))
@admin.route('/users')

View File

@@ -28,6 +28,11 @@ def index():
return render_template('community/index.html', posts=posts, posts_with_routes=posts_with_routes)
@community.route('/test-map')
def test_map():
"""Test map page for debugging"""
return render_template('community/test_map.html')
@community.route('/post/<int:id>')
def post_detail(id):
"""Individual post detail page"""
@@ -666,47 +671,68 @@ def save_gpx_file(gpx_file, post_id):
@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:
"""API endpoint to get all routes for map display - database optimized"""
try:
from app.utils.gpx_processor import get_all_map_routes
routes_data = get_all_map_routes()
# Add additional post information and format for frontend
formatted_routes = []
for route_data in routes_data:
try:
# Read and parse GPX file using new folder structure
if post.media_folder:
gpx_path = os.path.join(current_app.root_path, 'static', 'media', 'posts',
post.media_folder, 'gpx', gpx_file.filename)
else:
# Fallback to old path for existing files
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)
})
formatted_routes.append({
'id': route_data['post_id'],
'title': route_data['post_title'],
'author': route_data['post_author'],
'coordinates': route_data['coordinates'],
'url': url_for('community.post_detail', id=route_data['post_id']),
'distance': route_data['stats']['distance'],
'elevation_gain': route_data['stats']['elevation_gain'],
'max_elevation': route_data['stats']['max_elevation'],
'total_points': len(route_data['coordinates']),
'start_point': route_data['start_point'],
'end_point': route_data['end_point'],
'bounds': route_data['bounds']
})
except Exception as e:
current_app.logger.error(f'Error processing GPX file {gpx_file.filename}: {str(e)}')
current_app.logger.error(f'Error formatting route data: {str(e)}')
continue
return jsonify(routes_data)
return jsonify(formatted_routes)
except Exception as e:
current_app.logger.error(f'Error getting map routes: {str(e)}')
return jsonify([]) # Return empty array on error
@community.route('/api/route/<int:post_id>')
def api_route_detail(post_id):
"""API endpoint to get detailed route data for a specific post"""
try:
from app.utils.gpx_processor import get_post_route_details
route_data = get_post_route_details(post_id)
if not route_data:
return jsonify({'error': 'Route not found'}), 404
# Format for frontend
formatted_route = {
'id': route_data['post_id'],
'coordinates': route_data['coordinates'],
'simplified_coordinates': route_data['simplified_coordinates'],
'start_point': route_data['start_point'],
'end_point': route_data['end_point'],
'bounds': route_data['bounds'],
'stats': route_data['stats']
}
return jsonify(formatted_route)
except Exception as e:
current_app.logger.error(f'Error getting route details for post {post_id}: {str(e)}')
return jsonify({'error': 'Internal server error'}), 500
@community.route('/media/posts/<post_folder>/images/<filename>')
def serve_image(post_folder, filename):

View File

@@ -24,6 +24,18 @@ def health_check():
"""Health check endpoint"""
return {'status': 'healthy', 'timestamp': datetime.utcnow().isoformat()}
@main.route('/map-test')
def map_test():
"""Serve the map test page"""
from flask import send_from_directory
return send_from_directory('static', 'map_test.html')
@main.route('/basic-map-test')
def basic_map_test():
"""Serve the basic map test page"""
from flask import send_from_directory
return send_from_directory('static', 'basic_map_test.html')
@main.app_errorhandler(404)
def not_found_error(error):
return render_template('errors/404.html'), 404

345
app/static/map_iframe.html Normal file
View File

@@ -0,0 +1,345 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Adventure Routes Map</title>
<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>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
overflow: hidden;
}
#map {
height: 100vh;
width: 100vw;
}
.map-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 1000;
background: rgba(255, 255, 255, 0.95);
padding: 20px;
border-radius: 8px;
text-align: center;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #f97316;
border-radius: 50%;
width: 30px;
height: 30px;
animation: spin 1s linear infinite;
margin: 0 auto 10px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.route-popup {
min-width: 250px;
font-family: inherit;
}
.route-popup h3 {
margin: 0 0 10px 0;
color: #1f2937;
font-size: 1.1em;
font-weight: 600;
}
.route-popup .stat {
display: flex;
justify-content: space-between;
margin: 5px 0;
padding: 2px 0;
border-bottom: 1px solid #e5e7eb;
}
.route-popup .stat:last-of-type {
border-bottom: none;
}
.route-popup .stat strong {
color: #374151;
}
.route-popup .view-btn {
display: inline-block;
margin-top: 10px;
padding: 8px 16px;
background: linear-gradient(45deg, #f97316, #ea580c);
color: white;
text-decoration: none;
border-radius: 6px;
font-size: 0.9em;
transition: all 0.2s;
}
.route-popup .view-btn:hover {
background: linear-gradient(45deg, #ea580c, #dc2626);
transform: translateY(-1px);
}
.map-controls {
position: absolute;
top: 10px;
right: 10px;
z-index: 1000;
display: flex;
flex-direction: column;
gap: 5px;
}
.map-control-btn {
background: white;
border: 2px solid #ddd;
border-radius: 6px;
width: 40px;
height: 40px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 16px;
transition: all 0.2s;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
}
.map-control-btn:hover {
background: #f3f4f6;
transform: scale(1.05);
}
.map-control-btn.active {
background: #f97316;
color: white;
border-color: #ea580c;
}
</style>
</head>
<body>
<div id="map">
<div id="map-loading" class="map-loading">
<div class="spinner"></div>
<div>Loading adventure routes...</div>
</div>
</div>
<!-- Map Controls -->
<div class="map-controls">
<button id="fit-routes" class="map-control-btn" title="Fit all routes">🎯</button>
<button id="toggle-routes" class="map-control-btn active" title="Toggle routes">🛣️</button>
<button id="refresh-map" class="map-control-btn" title="Refresh map">🔄</button>
</div>
<script>
let map;
let routeLayer;
let routesVisible = true;
function initializeMap() {
console.log('Initializing standalone map...');
// Romania center and zoom for default view
const romaniaCenter = [45.9432, 24.9668]; // Center of Romania
const romaniaZoom = 7;
// Create map
map = L.map('map', {
zoomControl: true,
scrollWheelZoom: true,
doubleClickZoom: true,
touchZoom: true
}).setView(romaniaCenter, romaniaZoom);
// Add OpenStreetMap tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 18,
minZoom: 5
}).addTo(map);
console.log('Base map created');
// Create route layer group
routeLayer = L.layerGroup().addTo(map);
// Load routes
loadRoutes();
// Setup controls
setupControls();
}
function loadRoutes() {
console.log('Loading routes from API...');
// Get the parent window's origin for API calls
const apiUrl = window.location.origin + '/community/api/routes';
fetch(apiUrl)
.then(response => {
console.log('API response status:', response.status);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
})
.then(routesData => {
console.log(`Loaded ${routesData.length} routes`);
// Hide loading indicator
const loading = document.getElementById('map-loading');
if (loading) {
loading.style.display = 'none';
}
if (routesData.length === 0) {
console.log('No routes found');
return;
}
// Route colors
const colors = [
'#f97316', '#dc2626', '#059669', '#7c3aed',
'#db2777', '#2563eb', '#7c2d12', '#065f46'
];
const allBounds = [];
// Add each route
routesData.forEach((route, index) => {
if (route.coordinates && route.coordinates.length > 0) {
const color = colors[index % colors.length];
// Create polyline
const polyline = L.polyline(route.coordinates, {
color: color,
weight: 4,
opacity: 0.8,
smoothFactor: 1
});
// Create popup content
const popupContent = `
<div class="route-popup">
<h3>🏍️ ${route.title}</h3>
<div class="stat">
<span>👤 Author:</span>
<strong>${route.author}</strong>
</div>
<div class="stat">
<span>📏 Distance:</span>
<strong>${route.distance.toFixed(2)} km</strong>
</div>
<div class="stat">
<span>⛰️ Elevation Gain:</span>
<strong>${route.elevation_gain.toFixed(0)} m</strong>
</div>
<div class="stat">
<span>🏔️ Max Elevation:</span>
<strong>${route.max_elevation.toFixed(0)} m</strong>
</div>
<a href="${route.url}" target="_parent" class="view-btn">
🔍 View Adventure Details
</a>
</div>
`;
polyline.bindPopup(popupContent);
// Add to route layer
routeLayer.addLayer(polyline);
// Collect bounds for fitting
allBounds.push(...route.coordinates);
console.log(`Added route: ${route.title} (${route.coordinates.length} points)`);
}
});
console.log(`Successfully loaded ${routesData.length} routes`);
// Do NOT auto-fit map to routes on load; keep Romania view/zoom
// Only fit to routes when user clicks the 🎯 button
// (see setupControls)
})
.catch(error => {
console.error('Error loading routes:', error);
const loading = document.getElementById('map-loading');
if (loading) {
loading.innerHTML = `
<div style="color: #dc2626;">
<div style="font-size: 1.2em; margin-bottom: 8px;">⚠️</div>
<div>Failed to load routes</div>
<div style="font-size: 0.8em; margin-top: 5px; color: #6b7280;">
${error.message}
</div>
</div>
`;
}
});
}
function setupControls() {
// Fit routes button
document.getElementById('fit-routes').addEventListener('click', () => {
if (routeLayer.getLayers().length > 0) {
const group = new L.FeatureGroup();
routeLayer.eachLayer(layer => group.addLayer(layer));
map.fitBounds(group.getBounds(), { padding: [20, 20] });
}
});
// Toggle routes button
document.getElementById('toggle-routes').addEventListener('click', (e) => {
if (routesVisible) {
map.removeLayer(routeLayer);
e.target.classList.remove('active');
routesVisible = false;
} else {
map.addLayer(routeLayer);
e.target.classList.add('active');
routesVisible = true;
}
});
// Refresh map button
document.getElementById('refresh-map').addEventListener('click', () => {
routeLayer.clearLayers();
loadRoutes();
});
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeMap);
} else {
initializeMap();
}
// Handle resize
window.addEventListener('resize', () => {
if (map) {
setTimeout(() => map.invalidateSize(), 100);
}
});
</script>
</body>
</html>

View File

@@ -0,0 +1,99 @@
<!DOCTYPE html>
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Route Map (Debug)</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" crossorigin=""/>
<style>
html, body { height: 100%; margin: 0; padding: 0; }
#map { width: 100vw; height: 100vh; min-height: 100%; min-width: 100%; border-radius: 1rem; }
.leaflet-container { background: #f8f9fa; }
</style>
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js" crossorigin=""></script>
<script>
// Get route_id from query string
function getRouteId() {
const params = new URLSearchParams(window.location.search);
return params.get('route_id');
}
const routeId = getRouteId();
const map = L.map('map');
// Set map size to fit iframe
function resizeMap() {
const mapDiv = document.getElementById('map');
mapDiv.style.width = window.innerWidth + 'px';
mapDiv.style.height = window.innerHeight + 'px';
map.invalidateSize();
}
window.addEventListener('resize', resizeMap);
// Initial size
resizeMap();
// Add OSM tiles
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors'
}).addTo(map);
// Fetch route data
if (routeId) {
fetch(`/community/api/route/${routeId}`)
.then(response => response.json())
.then(data => {
if (data && data.coordinates && data.coordinates.length > 0) {
const latlngs = data.coordinates.map(pt => [pt[1], pt[0]]);
const polyline = L.polyline(latlngs, { color: '#ef4444', weight: 5, opacity: 0.9 }).addTo(map);
map.fitBounds(polyline.getBounds(), { padding: [20, 20], maxZoom: 15 });
// Start marker
L.marker(latlngs[0], { icon: L.divIcon({ html: '<div style="background:#22c55e;border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:#fff;"><i class=\'fas fa-play\'></i></div>', className: 'custom-div-icon', iconSize: [24,24], iconAnchor: [12,12] }) }).addTo(map);
// End marker
L.marker(latlngs[latlngs.length-1], { icon: L.divIcon({ html: '<div style="background:#ef4444;border-radius:50%;width:24px;height:24px;display:flex;align-items:center;justify-content:center;color:#fff;"><i class=\'fas fa-flag-checkered\'></i></div>', className: 'custom-div-icon', iconSize: [24,24], iconAnchor: [12,12] }) }).addTo(map);
} else {
map.setView([45.9432, 24.9668], 6);
L.marker([45.9432, 24.9668]).addTo(map).bindPopup('No route data').openPopup();
}
})
.catch(err => {
map.setView([45.9432, 24.9668], 6);
L.marker([45.9432, 24.9668]).addTo(map).bindPopup('Error loading route').openPopup();
});
} else {
map.setView([45.9432, 24.9668], 6);
L.marker([45.9432, 24.9668]).addTo(map).bindPopup('No route selected').openPopup();
}
</script>
</body>
</html>
color: '#f97316', weight: 5, opacity: 0.9, smoothFactor: 1
}).addTo(routeLayer);
polyline.bindPopup(`<div><h3>🏍️ ${route.title}</h3><div>Distance: ${route.distance.toFixed(2)} km</div><div>Elevation Gain: ${route.elevation_gain.toFixed(0)} m</div></div>`);
// Always fit bounds to the route and bind to frame size
function fitRouteBounds() {
map.invalidateSize();
map.fitBounds(polyline.getBounds(), { padding: [10, 10], maxZoom: 18 });
}
fitRouteBounds();
window.addEventListener('resize', fitRouteBounds);
})
.catch(error => showError(error.message));
}
function showError(msg) {
const loading = document.getElementById('map-loading');
loading.innerHTML = `<div style='color:#dc2626;'><div style='font-size:1.2em;margin-bottom:8px;'>⚠️</div><div>${msg}</div></div>`;
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeMap);
} else {
initializeMap();
}
window.addEventListener('resize', () => { if (map) setTimeout(() => map.invalidateSize(), 100); });
</script>
</body>
</html>

View File

@@ -208,7 +208,7 @@
</div>
<div class="card-body">
{% if post.published %}
<a href="{{ url_for('community.post_detail', post_id=post.id) }}"
<a href="{{ url_for('community.post_detail', 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>

View File

@@ -60,6 +60,9 @@
{% if post.subtitle %}
<br><small class="text-muted">{{ post.subtitle[:80] }}{% if post.subtitle|length > 80 %}...{% endif %}</small>
{% endif %}
{% if post.gpx_files.count() > 0 %}
<!-- Map iframe removed as requested -->
{% endif %}
</div>
</td>
<td>

View File

@@ -6,8 +6,6 @@
<title>{% block title %}Moto Adventure Community{% endblock %}</title>
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
<script src="https://cdn.tailwindcss.com"></script>
<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>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head>
<body class="bg-gray-50">

View File

@@ -3,42 +3,116 @@
{% 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;
/* 3-row grid card layout */
.map-card {
display: flex;
flex-direction: column;
min-height: 800px;
background: rgba(255,255,255,0.07);
border-radius: 1.5rem;
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
border: 2px solid #10b981;
padding: 0;
overflow: hidden;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.route-popup {
font-family: inherit;
.map-card-square {
width: 100%;
max-width: 700px;
aspect-ratio: 1 / 1;
margin: 0 auto;
background: rgba(255,255,255,0.07);
border-radius: 1.5rem;
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
border: 2px solid #10b981;
display: flex;
flex-direction: column;
overflow: hidden;
}
.route-popup .popup-title {
font-weight: 600;
color: #1f2937;
margin-bottom: 0.25rem;
.map-card-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 0.75rem 1.25rem 0.5rem 1.25rem;
}
.route-popup .popup-author {
color: #6b7280;
font-size: 0.875rem;
margin-bottom: 0.5rem;
.map-card-title {
font-size: 1.1rem;
font-weight: bold;
color: #fff;
}
.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;
.map-card-count {
font-size: 0.95rem;
color: #a5b4fc;
text-align: right;
}
.route-popup .popup-link:hover {
transform: translateY(-1px);
box-shadow: 0 4px 8px rgba(0, 0, 0, 0.2);
.map-card-content {
flex: 1 1 0%;
display: flex;
align-items: center;
justify-content: center;
min-height: 0;
min-width: 0;
padding: 0;
}
.map-iframe-container {
width: 100%;
height: 100%;
aspect-ratio: 1 / 1;
background: #f0f0f0;
border-radius: 1rem;
box-shadow: 0 25px 50px -12px rgba(0,0,0,0.25);
border: 2px solid #10b981;
position: relative;
display: flex;
align-items: center;
justify-content: center;
margin: 0 auto;
overflow: hidden;
}
.map-iframe {
width: 100%;
height: 100%;
aspect-ratio: 1 / 1;
border: none;
display: block;
border-radius: 1rem;
background: #f0f0f0;
}
/* Loading state for iframe */
.iframe-loading {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
z-index: 10;
background: rgba(255, 255, 255, 0.95);
padding: 20px;
border-radius: 12px;
text-align: center;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255,255,255,0.2);
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
min-width: 200px;
transition: opacity 0.3s ease;
}
.iframe-loading.hidden {
opacity: 0;
pointer-events: none;
}
.map-card-footer {
padding: 0.5rem 1.25rem 0.75rem 1.25rem;
background: transparent;
text-align: center;
}
@media (max-width: 900px) {
.map-iframe-container, .map-iframe {
height: 40vh;
min-height: 200px;
}
}
</style>
{% endblock %}
@@ -48,29 +122,44 @@
<!-- 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">
<div class="relative max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pt-2 pb-2">
<div class="text-center mb-2">
<h1 class="text-2xl md:text-3xl font-bold text-white mb-1">
🏍️ Motorcycle Adventures Romania
</h1>
<p class="text-lg text-blue-100 max-w-3xl mx-auto">
<p class="text-base text-blue-100 max-w-2xl 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 -->
<!-- Interactive Map Section (Wide Rectangular Card) -->
<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="w-11/12 md:w-10/12 lg:w-5/6 xl:w-11/12 mx-auto bg-white/10 backdrop-blur-sm rounded-2xl p-4 border border-white/20 flex flex-col" style="">
<div class="flex justify-between items-center mb-2">
<h2 class="text-xl 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">
<div class="flex-1 flex items-center justify-center">
<div class="w-full aspect-[10/7] rounded-xl overflow-hidden border-2 border-emerald-500 bg-gray-100 relative">
<!-- Loading indicator -->
<div id="iframe-loading" class="absolute inset-0 flex flex-col items-center justify-center bg-white/80 z-10">
<div class="animate-spin rounded-full h-5 w-5 border-b-2 border-orange-600 mb-2"></div>
<span class="text-gray-700 text-sm">Loading interactive map...</span>
</div>
<iframe
id="map-iframe"
src="{{ url_for('static', filename='map_iframe.html') }}"
class="w-full h-full border-0 rounded-xl bg-gray-100 aspect-[10/7]"
title="Adventure Routes Map"
onload="hideIframeLoading()">
</iframe>
</div>
</div>
<p class="text-blue-200 text-xs mt-2 text-center">
Click on any route to view the adventure story • Routes are updated live as new trips are shared
</p>
</div>
@@ -268,68 +357,24 @@
</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);
});
});
// Function to hide iframe loading indicator
function hideIframeLoading() {
const loadingEl = document.getElementById('iframe-loading');
if (loadingEl) {
loadingEl.classList.add('hidden');
setTimeout(() => {
loadingEl.style.display = 'none';
}, 300);
}
}
// Fallback to hide loading after 5 seconds if iframe doesn't trigger onload
setTimeout(() => {
const loadingEl = document.getElementById('iframe-loading');
if (loadingEl && !loadingEl.classList.contains('hidden')) {
console.log('Iframe loading timeout, hiding loading indicator');
hideIframeLoading();
}
}, 5000);
</script>
{% endblock %}

View File

@@ -419,6 +419,26 @@
<div class="space-y-8">
<!-- GPS Map and Route Information -->
{% if post.gpx_files.count() > 0 %}
<!-- Map Card (single route, styled like Adventure Info) -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden mb-8">
<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-map-marked-alt text-2xl mr-3"></i>
<div>
<h2 class="text-xl font-bold">Map</h2>
<p class="text-blue-100">Full trip view</p>
</div>
</div>
</div>
<div class="p-0" style="height:400px;overflow:hidden;">
<iframe
id="route-map-iframe"
src="{{ url_for('static', filename='map_iframe_single.html') }}?route_id={{ post.gpx_files[0].id }}"
width="100%" height="400" style="border:0; border-radius:1rem; display:block; background:#f8f9fa;"
allowfullscreen loading="lazy" referrerpolicy="no-referrer-when-downgrade">
</iframe>
</div>
</div>
<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">
@@ -491,44 +511,7 @@
</div>
<!-- Interactive Map Card -->
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
<div class="bg-gradient-to-r from-teal-600 to-cyan-600 p-6">
<div class="flex items-center justify-between text-white">
<div class="flex items-center">
<i class="fas fa-map text-2xl mr-3"></i>
<div>
<h2 class="text-xl font-bold">Interactive Route Map</h2>
<p class="text-teal-100">Explore the full route</p>
</div>
</div>
<button onclick="expandMap()"
class="px-4 py-2 bg-white/20 backdrop-blur-sm border border-white/30 text-white font-semibold rounded-lg hover:bg-white/30 transition-all duration-200">
<i class="fas fa-expand mr-2"></i>
Expand
</button>
</div>
</div>
<div class="p-6">
<div class="relative">
<div id="interactive-map" class="map-container shadow-lg">
<!-- Fallback content if map doesn't load -->
<div id="map-fallback" class="absolute inset-0 flex items-center justify-center bg-gray-100 text-gray-600">
<div class="text-center">
<i class="fas fa-map text-4xl mb-4 text-gray-400"></i>
<div class="font-semibold">Loading Interactive Map...</div>
<div class="text-sm mt-2">If map doesn't load, please refresh the page</div>
</div>
</div>
</div>
<div id="map-loading" class="map-loading">
<div class="flex items-center space-x-3">
<div class="animate-spin rounded-full h-6 w-6 border-b-2 border-teal-600"></div>
<span class="text-gray-700 font-medium">Loading route...</span>
</div>
</div>
</div>
</div>
</div>
<!-- Interactive Map Card removed as requested -->
{% endif %}
<!-- Adventure Info -->
@@ -568,6 +551,14 @@
<span class="text-gray-600">Photos</span>
<span class="text-gray-900">{{ post.images.count() }}</span>
</div>
<!-- Interactive Map Card (iframe, styled as in community index) -->
<div class="map-card-square mt-8 mb-8 mx-auto">
<div class="map-card-header">
<span class="map-card-title">🗺️ Interactive Route Map</span>
<span class="map-card-count">1 route</span>
</div>
</div>
{% endif %}
{% if post.gpx_files.count() > 0 %}
<div class="flex items-center justify-between">
@@ -908,7 +899,6 @@ function toggleLike(postId) {
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) {

View File

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

View File

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

View File

@@ -0,0 +1,38 @@
import os
from app import db
from app.models import Post, GPXFile
def clean_orphan_post_media(media_root='app/static/media/posts'):
"""
Remove folders in app/static/media/posts that do not have a corresponding Post in the database.
"""
# Get all valid media_folder names from the database
valid_folders = set(post.media_folder for post in Post.query.all() if post.media_folder)
# List all folders in the media root
for folder in os.listdir(media_root):
folder_path = os.path.join(media_root, folder)
if os.path.isdir(folder_path) and folder not in valid_folders:
print(f"Deleting orphaned media folder: {folder_path}")
# Recursively delete the folder and its contents
for root, dirs, files in os.walk(folder_path, topdown=False):
for name in files:
os.remove(os.path.join(root, name))
for name in dirs:
os.rmdir(os.path.join(root, name))
os.rmdir(folder_path)
# --- Remove orphaned GPXFile records ---
# Get all valid post IDs
valid_post_ids = set(post.id for post in Post.query.all())
# Find GPXFile records whose post_id is not in valid_post_ids
orphaned_gpx_files = GPXFile.query.filter(~GPXFile.post_id.in_(valid_post_ids)).all()
if orphaned_gpx_files:
print(f"Deleting {len(orphaned_gpx_files)} orphaned GPXFile records from the database.")
for gpx in orphaned_gpx_files:
db.session.delete(gpx)
db.session.commit()
else:
print("No orphaned GPXFile records found.")
print("Orphaned media and GPXFile cleanup complete.")

View File

@@ -1,11 +1,14 @@
"""
GPX file processing utilities for extracting route statistics
GPX file processing utilities for extracting route statistics and creating map routes
"""
import xml.etree.ElementTree as ET
import math
import os
from typing import Dict, Optional, Tuple
import json
import gpxpy
from typing import Dict, Optional, Tuple, List
from app import db
def calculate_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
@@ -174,3 +177,344 @@ def process_gpx_file(gpx_file_record) -> bool:
gpx_file_record.total_points = stats['total_points']
return True
def simplify_coordinates(coordinates: List[List[float]], tolerance: float = 0.0001) -> List[List[float]]:
"""
Simplify coordinates using Douglas-Peucker algorithm
Returns a reduced set of points while maintaining the general shape
"""
if len(coordinates) <= 2:
return coordinates
def perpendicular_distance(point, line_start, line_end):
"""Calculate perpendicular distance from point to line"""
if line_start == line_end:
return ((point[0] - line_start[0]) ** 2 + (point[1] - line_start[1]) ** 2) ** 0.5
# Calculate the perpendicular distance
A = point[0] - line_start[0]
B = point[1] - line_start[1]
C = line_end[0] - line_start[0]
D = line_end[1] - line_start[1]
dot = A * C + B * D
len_sq = C * C + D * D
if len_sq == 0:
return (A * A + B * B) ** 0.5
param = dot / len_sq
if param < 0:
xx = line_start[0]
yy = line_start[1]
elif param > 1:
xx = line_end[0]
yy = line_end[1]
else:
xx = line_start[0] + param * C
yy = line_start[1] + param * D
dx = point[0] - xx
dy = point[1] - yy
return (dx * dx + dy * dy) ** 0.5
def douglas_peucker(points, tolerance):
"""Douglas-Peucker algorithm implementation"""
if len(points) <= 2:
return points
# Find the point with maximum distance
max_dist = 0
index = 0
for i in range(1, len(points) - 1):
dist = perpendicular_distance(points[i], points[0], points[-1])
if dist > max_dist:
index = i
max_dist = dist
# If max distance is greater than tolerance, recursively simplify
if max_dist > tolerance:
# Recursive call
rec_results1 = douglas_peucker(points[:index + 1], tolerance)
rec_results2 = douglas_peucker(points[index:], tolerance)
# Build the result list
result = rec_results1[:-1] + rec_results2
return result
else:
return [points[0], points[-1]]
return douglas_peucker(coordinates, tolerance)
def calculate_bounds(coordinates: List[List[float]]) -> Optional[Dict]:
"""Calculate bounding box for coordinates"""
if not coordinates:
return None
lats = [coord[0] for coord in coordinates]
lngs = [coord[1] for coord in coordinates]
return {
'north': max(lats),
'south': min(lats),
'east': max(lngs),
'west': min(lngs)
}
def create_map_route_from_gpx(gpx_file_id: int) -> bool:
"""
Process a GPX file and create/update corresponding MapRoute entry
"""
try:
from app.models import MapRoute, GPXFile, Post
from flask import current_app
# Get the GPX file record
gpx_file = GPXFile.query.get(gpx_file_id)
if not gpx_file:
print(f"GPX file with ID {gpx_file_id} not found")
return False
# Get the file path
if gpx_file.post.media_folder:
file_path = os.path.join(
current_app.root_path, 'static', 'media', 'posts',
gpx_file.post.media_folder, 'gpx', gpx_file.filename
)
else:
file_path = os.path.join(
current_app.root_path, 'static', 'uploads', 'gpx', gpx_file.filename
)
if not os.path.exists(file_path):
print(f"GPX file not found: {file_path}")
return False
# Parse GPX file using gpxpy for better coordinate extraction
try:
with open(file_path, 'r', encoding='utf-8') as f:
gpx = gpxpy.parse(f)
except Exception as e:
print(f"Error parsing GPX file with gpxpy: {e}")
return False
# Extract coordinates from all tracks and segments
all_coordinates = []
total_distance = 0
elevations = []
for track in gpx.tracks:
for segment in track.segments:
prev_point = None
for point in segment.points:
coord = [point.latitude, point.longitude]
all_coordinates.append(coord)
if point.elevation is not None:
elevations.append(point.elevation)
# Calculate distance
if prev_point:
distance = prev_point.distance_2d(point)
if distance:
total_distance += distance
prev_point = point
# If no track points, try waypoints
if not all_coordinates:
for waypoint in gpx.waypoints:
coord = [waypoint.latitude, waypoint.longitude]
all_coordinates.append(coord)
if waypoint.elevation is not None:
elevations.append(waypoint.elevation)
if not all_coordinates:
print("No coordinates found in GPX file")
return False
# Calculate statistics
elevation_gain = 0
if elevations:
for i in range(1, len(elevations)):
gain = elevations[i] - elevations[i-1]
if gain > 0:
elevation_gain += gain
# Simplify coordinates for overview map
simplified_coords = simplify_coordinates(all_coordinates, tolerance=0.001)
# Calculate bounds
bounds = calculate_bounds(all_coordinates)
if not bounds:
print("Could not calculate bounds for coordinates")
return False
# Create or update MapRoute
existing_route = MapRoute.query.filter_by(post_id=gpx_file.post_id).first()
if existing_route:
# Update existing route
map_route = existing_route
else:
# Create new route
map_route = MapRoute(post_id=gpx_file.post_id, gpx_file_id=gpx_file_id)
# Set route data
map_route.coordinates = json.dumps(all_coordinates)
map_route.simplified_coordinates = json.dumps(simplified_coords)
# Set start and end points
map_route.start_latitude = all_coordinates[0][0]
map_route.start_longitude = all_coordinates[0][1]
map_route.end_latitude = all_coordinates[-1][0]
map_route.end_longitude = all_coordinates[-1][1]
# Set bounds
map_route.bounds_north = bounds['north']
map_route.bounds_south = bounds['south']
map_route.bounds_east = bounds['east']
map_route.bounds_west = bounds['west']
# Set statistics
map_route.total_distance = total_distance / 1000 if total_distance else 0 # Convert to kilometers
map_route.elevation_gain = elevation_gain
map_route.max_elevation = max(elevations) if elevations else 0
map_route.min_elevation = min(elevations) if elevations else 0
map_route.total_points = len(all_coordinates)
map_route.simplified_points = len(simplified_coords)
# Save to database
if not existing_route:
db.session.add(map_route)
db.session.commit()
print(f"Successfully created map route for GPX file {gpx_file.filename} (Post {gpx_file.post_id})")
print(f"- Total points: {len(all_coordinates)}")
print(f"- Simplified points: {len(simplified_coords)}")
print(f"- Distance: {map_route.total_distance:.2f} km")
print(f"- Elevation gain: {map_route.elevation_gain:.0f} m")
return True
except Exception as e:
db.session.rollback()
print(f"Error creating map route for GPX file {gpx_file_id}: {str(e)}")
return False
def process_post_approval(post_id: int) -> bool:
"""
Process all GPX files for a post when it gets approved
Creates map routes for efficient map loading
"""
try:
from app.models import Post, GPXFile
post = Post.query.get(post_id)
if not post:
print(f"Post with ID {post_id} not found")
return False
# Get all GPX files for this post
gpx_files = GPXFile.query.filter_by(post_id=post_id).all()
if not gpx_files:
print(f"No GPX files found for post {post_id}")
return True # Not an error if no GPX files
# Process the first GPX file (assuming one route per post)
# If multiple files exist, you might want to merge them or process separately
gpx_file = gpx_files[0]
success = create_map_route_from_gpx(gpx_file.id)
if success:
print(f"Successfully processed post {post_id} approval - map route created")
else:
print(f"Failed to create map route for post {post_id}")
return success
except Exception as e:
print(f"Error processing post approval for post {post_id}: {str(e)}")
return False
def get_all_map_routes() -> List[Dict]:
"""
Get all map routes for the community map
Returns simplified data optimized for map display
"""
try:
from app.models import MapRoute, Post
routes = MapRoute.query.join(Post).filter(Post.published == True).all()
map_data = []
for route in routes:
try:
map_data.append({
'id': route.id,
'post_id': route.post_id,
'post_title': route.post.title,
'post_author': route.post.author.nickname,
'coordinates': route.get_simplified_coordinates_json(),
'start_point': route.get_start_point(),
'end_point': route.get_end_point(),
'bounds': route.get_bounds(),
'stats': {
'distance': route.total_distance,
'elevation_gain': route.elevation_gain,
'max_elevation': route.max_elevation
}
})
except Exception as e:
print(f"Error processing route {route.id}: {e}")
continue
return map_data
except Exception as e:
print(f"Error getting map routes: {str(e)}")
return []
def get_post_route_details(post_id: int) -> Optional[Dict]:
"""
Get detailed route data for a specific post
Returns full coordinate data for detailed view
"""
try:
from app.models import MapRoute
route = MapRoute.query.filter_by(post_id=post_id).first()
if not route:
return None
return {
'id': route.id,
'post_id': route.post_id,
'coordinates': route.get_coordinates_json(),
'simplified_coordinates': route.get_simplified_coordinates_json(),
'start_point': route.get_start_point(),
'end_point': route.get_end_point(),
'bounds': route.get_bounds(),
'stats': {
'distance': route.total_distance,
'elevation_gain': route.elevation_gain,
'max_elevation': route.max_elevation,
'min_elevation': route.min_elevation,
'total_points': route.total_points,
'simplified_points': route.simplified_points
}
}
except Exception as e:
print(f"Error getting route details for post {post_id}: {str(e)}")
return None