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

202
MAP_SYSTEM.md Normal file
View File

@@ -0,0 +1,202 @@
# Database-Driven Map System
This document describes the new database-driven map system that provides efficient and reliable loading of GPX routes on the community map.
## Overview
The map system has been redesigned to use pre-processed route data stored in the database instead of real-time GPX file processing. This approach provides:
- **Faster Loading**: Routes load instantly from the database
- **Better Performance**: No real-time file parsing during map display
- **Simplified Coordinates**: Routes are automatically simplified for overview maps
- **Reliable Display**: Eliminates container sizing and timing issues
- **Cached Statistics**: Route statistics are pre-calculated and stored
## Database Schema
### MapRoute Table
The `map_routes` table stores pre-processed route data:
```sql
CREATE TABLE map_routes (
id INTEGER PRIMARY KEY,
coordinates TEXT NOT NULL, -- JSON array of [lat, lng] points
simplified_coordinates TEXT, -- Simplified version for overview
start_latitude FLOAT NOT NULL, -- Route start point
start_longitude FLOAT NOT NULL,
end_latitude FLOAT NOT NULL, -- Route end point
end_longitude FLOAT NOT NULL,
bounds_north FLOAT NOT NULL, -- Bounding box
bounds_south FLOAT NOT NULL,
bounds_east FLOAT NOT NULL,
bounds_west FLOAT NOT NULL,
total_distance FLOAT DEFAULT 0.0, -- Distance in kilometers
elevation_gain FLOAT DEFAULT 0.0, -- Elevation gain in meters
max_elevation FLOAT DEFAULT 0.0, -- Maximum elevation
min_elevation FLOAT DEFAULT 0.0, -- Minimum elevation
total_points INTEGER DEFAULT 0, -- Original point count
simplified_points INTEGER DEFAULT 0, -- Simplified point count
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP,
post_id INTEGER NOT NULL UNIQUE, -- Foreign key to posts
gpx_file_id INTEGER NOT NULL -- Foreign key to gpx_files
);
```
## How It Works
### 1. Route Creation Process
When a post is **published** by an admin:
1. **Admin publishes post**`admin.publish_post()` function
2. **GPX processing triggered**`process_post_approval()` function
3. **Route data extracted** → Parse GPX file with `gpxpy`
4. **Coordinates simplified** → Douglas-Peucker algorithm reduces points
5. **Statistics calculated** → Distance, elevation, bounds computed
6. **Data stored** → All information saved to `map_routes` table
### 2. Map Display Process
When the community map loads:
1. **API called**`/community/api/routes` endpoint
2. **Database queried** → Only published posts with routes
3. **Data returned** → Pre-processed coordinates and metadata
4. **Map rendered** → Leaflet displays routes instantly
### 3. Coordinate Simplification
Routes are simplified using the Douglas-Peucker algorithm:
- **Original points**: Full GPX track (e.g., 1,849 points)
- **Simplified points**: Reduced set maintaining shape (e.g., 74 points)
- **Compression ratio**: Typically 95-98% reduction
- **Visual quality**: Preserved for map display
## API Endpoints
### Get All Routes
```
GET /community/api/routes
```
Returns simplified route data for all published posts:
```json
[
{
"id": 1,
"title": "Transalpina",
"author": "ske087",
"coordinates": [[45.409, 23.794], ...],
"distance": 41.26,
"elevation_gain": 2244,
"max_elevation": 1994,
"start_point": {"latitude": 45.409, "longitude": 23.794},
"end_point": {"latitude": 45.426, "longitude": 23.799},
"bounds": {"north": 45.426, "south": 45.378, ...}
}
]
```
### Get Route Details
```
GET /community/api/route/<post_id>
```
Returns detailed route data including full coordinates for individual post views.
## Management Tools
### Route Management Script
Use `manage_routes.py` for route management:
```bash
# Show all routes
python manage_routes.py list
# Show statistics
python manage_routes.py stats
# Create route for specific post
python manage_routes.py create 1
# Recreate all routes
python manage_routes.py recreate-all
# Clean up unpublished routes
python manage_routes.py cleanup
```
### Migration Script
The `migrations/create_map_routes.py` script:
- Creates the `map_routes` table
- Processes existing published posts with GPX files
- Generates route data for all current content
## Files Modified
### Core Files
- `app/models.py` - Added `MapRoute` model
- `app/utils/gpx_processor.py` - Extended with route creation functions
- `app/routes/community.py` - Updated API endpoints
- `app/routes/admin.py` - Added route creation on post publish
### Management Files
- `migrations/create_map_routes.py` - Database migration
- `manage_routes.py` - Route management utility
- `static/test_db_map.html` - Test page for verifying functionality
## Benefits Over Previous System
### Performance
- **Before**: Real-time GPX parsing (1-2 second delays)
- **After**: Instant database lookup (<100ms)
### Reliability
- **Before**: Container sizing issues, timing problems
- **After**: Consistent loading, no container dependencies
### Scalability
- **Before**: Processing time increases with file size
- **After**: Constant time regardless of original GPX size
### Maintenance
- **Before**: Debug JavaScript timing and CSS conflicts
- **After**: Simple database queries with management tools
## Troubleshooting
### No Routes Displayed
1. Check if posts are published: `python manage_routes.py list`
2. Verify API response: `curl http://localhost:5000/community/api/routes`
3. Check browser console for JavaScript errors
### Missing Route Data
1. Re-create routes: `python manage_routes.py recreate-all`
2. Check GPX file exists and is valid
3. Review server logs for processing errors
### Performance Issues
1. Check simplified point counts: `python manage_routes.py stats`
2. Verify database indexes on `post_id` and `published` fields
3. Monitor API response times
## Future Enhancements
### Possible Improvements
- **Route Clustering**: Group nearby routes on overview map
- **Elevation Profiles**: Store elevation data for profile charts
- **Route Segments**: Support for multi-segment routes
- **Cache Invalidation**: Automatic route updates when GPX files change
- **Batch Processing**: Background job processing for large GPX files
### Configuration Options
- **Simplification Tolerance**: Adjustable coordinate reduction level
- **Update Triggers**: Automatic processing on file upload
- **Storage Options**: Consider storing coordinates in PostGIS for spatial queries
This system provides a solid foundation for reliable map display while maintaining flexibility for future enhancements.

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

228
manage_routes.py Executable file
View File

@@ -0,0 +1,228 @@
#!/usr/bin/env python3
"""
Map Route Management Script
Utility for managing map routes in the database
"""
import sys
import os
from flask import Flask
# Add the project root to Python path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app import create_app, db
from app.models import Post, GPXFile, MapRoute, User
from app.utils.gpx_processor import create_map_route_from_gpx, process_post_approval
def show_help():
"""Show available commands"""
print("""
🗺️ Map Route Management Script
Available commands:
list - List all map routes
create <post_id> - Create map route for a specific post
recreate <post_id> - Recreate map route for a specific post
recreate-all - Recreate all map routes
stats - Show database statistics
cleanup - Remove map routes for unpublished posts
help - Show this help message
Examples:
python manage_routes.py list
python manage_routes.py create 1
python manage_routes.py recreate-all
python manage_routes.py stats
""")
def list_routes():
"""List all map routes"""
app = create_app()
with app.app_context():
routes = MapRoute.query.join(Post).all()
if not routes:
print("❌ No map routes found")
return
print(f"📍 Found {len(routes)} map routes:\n")
print("ID | Post | Title | Author | Published | Points | Distance")
print("-" * 70)
for route in routes:
status = "" if route.post.published else ""
print(f"{route.id:2d} | {route.post_id:4d} | {route.post.title[:20]:20s} | {route.post.author.nickname[:10]:10s} | {status:2s} | {route.simplified_points:6d} | {route.total_distance:7.2f} km")
def create_route(post_id):
"""Create map route for a specific post"""
app = create_app()
with app.app_context():
post = Post.query.get(post_id)
if not post:
print(f"❌ Post {post_id} not found")
return False
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 False
print(f"🔄 Creating map route for post {post_id}: {post.title}")
success = create_map_route_from_gpx(gpx_files[0].id)
if success:
print(f"✅ Successfully created map route for post {post_id}")
else:
print(f"❌ Failed to create map route for post {post_id}")
return success
def recreate_route(post_id):
"""Recreate map route for a specific post"""
app = create_app()
with app.app_context():
# Delete existing route
existing = MapRoute.query.filter_by(post_id=post_id).first()
if existing:
db.session.delete(existing)
db.session.commit()
print(f"🗑️ Deleted existing map route for post {post_id}")
# Create new route
return create_route(post_id)
def recreate_all_routes():
"""Recreate all map routes"""
app = create_app()
with app.app_context():
# Get all posts with GPX files
posts_with_gpx = db.session.query(Post).join(GPXFile).distinct().all()
if not posts_with_gpx:
print("❌ No posts with GPX files found")
return
print(f"🔄 Recreating map routes for {len(posts_with_gpx)} posts...")
# Delete all existing routes
MapRoute.query.delete()
db.session.commit()
print("🗑️ Deleted all existing map routes")
success_count = 0
error_count = 0
for post in posts_with_gpx:
print(f"\n🔄 Processing post {post.id}: {post.title}")
success = process_post_approval(post.id)
if success:
success_count += 1
else:
error_count += 1
print(f"\n📊 Results:")
print(f"✅ Successfully processed: {success_count}")
print(f"❌ Errors: {error_count}")
print(f"📍 Total posts: {len(posts_with_gpx)}")
def show_stats():
"""Show database statistics"""
app = create_app()
with app.app_context():
total_posts = Post.query.count()
published_posts = Post.query.filter_by(published=True).count()
posts_with_gpx = db.session.query(Post).join(GPXFile).distinct().count()
total_routes = MapRoute.query.count()
published_routes = MapRoute.query.join(Post).filter(Post.published == True).count()
print("📊 Database Statistics:")
print(f" 📝 Total posts: {total_posts}")
print(f" ✅ Published posts: {published_posts}")
print(f" 🗺️ Posts with GPX: {posts_with_gpx}")
print(f" 📍 Total map routes: {total_routes}")
print(f" 🌍 Published routes: {published_routes}")
if total_routes > 0:
routes = MapRoute.query.all()
total_points = sum(r.total_points for r in routes)
simplified_points = sum(r.simplified_points for r in routes)
total_distance = sum(r.total_distance for r in routes)
print(f"\n🎯 Route Statistics:")
print(f" 📊 Total coordinate points: {total_points:,}")
print(f" 🎯 Simplified points: {simplified_points:,}")
print(f" 📏 Total distance: {total_distance:.2f} km")
print(f" 🗜️ Compression ratio: {simplified_points/total_points*100:.1f}%")
def cleanup_routes():
"""Remove map routes for unpublished posts"""
app = create_app()
with app.app_context():
unpublished_routes = MapRoute.query.join(Post).filter(Post.published == False).all()
if not unpublished_routes:
print("✅ No unpublished routes to clean up")
return
print(f"🗑️ Found {len(unpublished_routes)} routes for unpublished posts")
for route in unpublished_routes:
print(f" Removing route for post {route.post_id}: {route.post.title}")
db.session.delete(route)
db.session.commit()
print(f"✅ Cleaned up {len(unpublished_routes)} routes")
def main():
if len(sys.argv) < 2:
show_help()
return
command = sys.argv[1].lower()
if command == 'help':
show_help()
elif command == 'list':
list_routes()
elif command == 'create':
if len(sys.argv) < 3:
print("❌ Please provide a post ID")
print("Usage: python manage_routes.py create <post_id>")
return
try:
post_id = int(sys.argv[2])
create_route(post_id)
except ValueError:
print("❌ Invalid post ID. Please provide a number.")
elif command == 'recreate':
if len(sys.argv) < 3:
print("❌ Please provide a post ID")
print("Usage: python manage_routes.py recreate <post_id>")
return
try:
post_id = int(sys.argv[2])
recreate_route(post_id)
except ValueError:
print("❌ Invalid post ID. Please provide a number.")
elif command == 'recreate-all':
recreate_all_routes()
elif command == 'stats':
show_stats()
elif command == 'cleanup':
cleanup_routes()
else:
print(f"❌ Unknown command: {command}")
show_help()
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,103 @@
"""
Database migration to add MapRoute table for efficient map loading
Run this script to create the new table structure
"""
import os
import sys
from flask import Flask
from flask_sqlalchemy import SQLAlchemy
# Add the project root to Python path
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from app import create_app, db
def create_map_route_table():
"""Create the MapRoute table"""
app = create_app()
with app.app_context():
try:
# Create the MapRoute table
db.create_all()
print("✅ MapRoute table created successfully!")
# Show current tables
inspector = db.inspect(db.engine)
tables = inspector.get_table_names()
print(f"Current tables: {', '.join(tables)}")
if 'map_routes' in tables:
print("✅ map_routes table confirmed in database")
else:
print("❌ map_routes table not found")
except Exception as e:
print(f"❌ Error creating MapRoute table: {e}")
return False
return True
def populate_existing_routes():
"""Process existing published posts with GPX files to create map routes"""
app = create_app()
with app.app_context():
try:
from app.models import Post, GPXFile, MapRoute
from app.utils.gpx_processor import create_map_route_from_gpx
# Find published posts with GPX files that don't have map routes yet
posts_with_gpx = db.session.query(Post).join(GPXFile).filter(
Post.published == True
).distinct().all()
print(f"Found {len(posts_with_gpx)} published posts with GPX files")
processed = 0
errors = 0
for post in posts_with_gpx:
# Check if map route already exists
existing_route = MapRoute.query.filter_by(post_id=post.id).first()
if existing_route:
print(f"⏭️ Post {post.id} already has a map route, skipping")
continue
# Get the first GPX file for this post
gpx_file = GPXFile.query.filter_by(post_id=post.id).first()
if gpx_file:
print(f"🔄 Processing post {post.id}: {post.title}")
success = create_map_route_from_gpx(gpx_file.id)
if success:
processed += 1
print(f"✅ Created map route for post {post.id}")
else:
errors += 1
print(f"❌ Failed to create map route for post {post.id}")
else:
print(f"⚠️ No GPX file found for post {post.id}")
print(f"\n📊 Summary:")
print(f"- Processed: {processed}")
print(f"- Errors: {errors}")
print(f"- Total posts with GPX: {len(posts_with_gpx)}")
except Exception as e:
print(f"❌ Error populating existing routes: {e}")
return False
return True
if __name__ == "__main__":
print("🚀 Creating MapRoute table and processing existing data...")
# Create the table
if create_map_route_table():
print("\n🔄 Processing existing published posts with GPX files...")
populate_existing_routes()
print("\n✅ Migration completed!")

25
run.py
View File

@@ -1,6 +1,6 @@
import os
from app import create_app, db
from app.models import User, Post, PostImage, GPXFile, Comment, Like, PageView
from app.models import User, Post, PostImage, GPXFile, Comment, Like, PageView, MapRoute
app = create_app()
@@ -14,7 +14,8 @@ def make_shell_context():
'GPXFile': GPXFile,
'Comment': Comment,
'Like': Like,
'PageView': PageView
'PageView': PageView,
'MapRoute': MapRoute
}
@app.cli.command()
@@ -121,6 +122,26 @@ def process_gpx_files():
db.session.commit()
print(f'Processed {processed}/{len(gpx_files)} GPX files')
@app.cli.command()
def set_cover_images():
"""Set cover images for posts that don't have them."""
posts = Post.query.all()
updated = 0
for post in posts:
# Check if post has a cover image
cover_image = post.images.filter_by(is_cover=True).first()
if not cover_image:
# Set the first image as cover if available
first_image = post.images.first()
if first_image:
first_image.is_cover = True
updated += 1
print(f'Set cover image for post: {post.title}')
db.session.commit()
print(f'Updated {updated} posts with cover images')
@app.cli.command()
def create_admin():
"""Create an admin user."""

View File

0
static/map_test.html Normal file
View File

0
static/test_db_map.html Normal file
View File

0
test_map.html Normal file
View File