Compare commits
6 Commits
73b90eafbc
...
187254beca
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
187254beca | ||
|
|
58e5d1b83d | ||
|
|
f9fcec83d5 | ||
|
|
d5c8ec1dc2 | ||
|
|
8691a6cd2d | ||
|
|
4fea7a6f49 |
@@ -125,6 +125,13 @@ class GPXFile(db.Model):
|
||||
size = db.Column(db.Integer, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow)
|
||||
|
||||
# GPX Statistics
|
||||
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) # number of track points
|
||||
|
||||
# Foreign Keys
|
||||
post_id = db.Column(db.Integer, db.ForeignKey('posts.id'), nullable=False)
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
from flask import Blueprint, render_template, request, flash, redirect, url_for, jsonify
|
||||
from flask import Blueprint, render_template, request, flash, redirect, url_for, jsonify, current_app
|
||||
from flask_login import login_required, current_user
|
||||
from functools import wraps
|
||||
from datetime import datetime, timedelta
|
||||
@@ -153,14 +153,34 @@ def unpublish_post(post_id):
|
||||
@admin_required
|
||||
def delete_post(post_id):
|
||||
"""Delete a post"""
|
||||
current_app.logger.info(f'Admin {current_user.id} attempting to delete post {post_id}')
|
||||
|
||||
# Get all posts before deletion for debugging
|
||||
all_posts_before = Post.query.all()
|
||||
current_app.logger.info(f'Posts before deletion: {[p.id for p in all_posts_before]}')
|
||||
|
||||
post = Post.query.get_or_404(post_id)
|
||||
title = post.title
|
||||
|
||||
# Delete associated files and records
|
||||
db.session.delete(post)
|
||||
db.session.commit()
|
||||
current_app.logger.info(f'Found post to delete: ID={post.id}, Title="{title}"')
|
||||
|
||||
try:
|
||||
# Delete associated files and records
|
||||
db.session.delete(post)
|
||||
db.session.commit()
|
||||
|
||||
# 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')
|
||||
|
||||
flash(f'Post "{title}" has been deleted.', 'success')
|
||||
return redirect(url_for('admin.posts'))
|
||||
|
||||
@admin.route('/users')
|
||||
|
||||
@@ -296,6 +296,15 @@ def new_post():
|
||||
post_id=post.id
|
||||
)
|
||||
db.session.add(gpx_file_record)
|
||||
db.session.flush() # Get the GPX file ID
|
||||
|
||||
# Extract GPX statistics
|
||||
from app.utils.gpx_processor import process_gpx_file
|
||||
if process_gpx_file(gpx_file_record):
|
||||
current_app.logger.info(f'GPX statistics extracted for: {result["filename"]}')
|
||||
else:
|
||||
current_app.logger.warning(f'Failed to extract GPX statistics for: {result["filename"]}')
|
||||
|
||||
current_app.logger.info(f'GPX file saved: {result["filename"]}')
|
||||
except Exception as e:
|
||||
current_app.logger.warning(f'Error processing GPX file: {str(e)}')
|
||||
|
||||
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 442 KiB |
|
After Width: | Height: | Size: 425 KiB |
|
After Width: | Height: | Size: 311 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 425 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 442 KiB |
|
After Width: | Height: | Size: 22 KiB |
|
After Width: | Height: | Size: 14 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 339 KiB |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 491 KiB |
|
After Width: | Height: | Size: 499 KiB |
|
After Width: | Height: | Size: 526 KiB |
|
After Width: | Height: | Size: 97 KiB |
|
After Width: | Height: | Size: 155 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 21 KiB |
@@ -126,9 +126,16 @@
|
||||
<div class="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{% for post in posts.items %}
|
||||
<article class="bg-white/10 backdrop-blur-sm rounded-xl overflow-hidden shadow-xl hover:shadow-2xl transition-all duration-300 border border-white/20 hover:border-white/40 transform hover:-translate-y-1">
|
||||
{% if post.images %}
|
||||
{% set cover_image = post.images.filter_by(is_cover=True).first() %}
|
||||
{% if cover_image %}
|
||||
<div class="aspect-w-16 aspect-h-9 bg-gray-900">
|
||||
<img src="{{ url_for('static', filename='uploads/images/' + post.images[0].filename) }}"
|
||||
<img src="{{ cover_image.get_url() }}"
|
||||
alt="{{ post.title }}"
|
||||
class="w-full h-48 object-cover">
|
||||
</div>
|
||||
{% elif post.images %}
|
||||
<div class="aspect-w-16 aspect-h-9 bg-gray-900">
|
||||
<img src="{{ post.images[0].get_url() }}"
|
||||
alt="{{ post.title }}"
|
||||
class="w-full h-48 object-cover">
|
||||
</div>
|
||||
|
||||
@@ -3,12 +3,161 @@
|
||||
{% 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://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
|
||||
crossorigin=""/>
|
||||
<style>
|
||||
.map-container {
|
||||
height: 400px !important;
|
||||
min-height: 400px;
|
||||
width: 100%;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
border: 2px solid #e5e7eb;
|
||||
background-color: #f8f9fa;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Ensure Leaflet map fills container */
|
||||
.leaflet-container {
|
||||
height: 100% !important;
|
||||
width: 100% !important;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Interactive map specific styling */
|
||||
#interactive-map {
|
||||
height: 400px !important;
|
||||
min-height: 400px;
|
||||
width: 100%;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
border: 2px solid #e5e7eb;
|
||||
position: relative;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Force map to be visible */
|
||||
#interactive-map .leaflet-container {
|
||||
height: 400px !important;
|
||||
width: 100% !important;
|
||||
}
|
||||
|
||||
/* Custom div icon styling */
|
||||
.custom-div-icon {
|
||||
background: transparent !important;
|
||||
border: none !important;
|
||||
}
|
||||
|
||||
/* Expanded map modal styling */
|
||||
#expanded-map {
|
||||
height: 100%;
|
||||
min-height: 70vh;
|
||||
border-radius: 0.5rem;
|
||||
}
|
||||
|
||||
/* Interactive map container */
|
||||
#interactive-map {
|
||||
height: 400px;
|
||||
border-radius: 1rem;
|
||||
overflow: hidden;
|
||||
border: 2px solid #e5e7eb;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* Modal animations */
|
||||
#expandedMapModal {
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
#expandedMapModal.hidden {
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Loading indicator styling */
|
||||
.map-loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
background: rgba(255, 255, 255, 0.95);
|
||||
padding: 1rem 1.5rem;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.1);
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
/* Custom leaflet control styling */
|
||||
.leaflet-control-zoom {
|
||||
border: none !important;
|
||||
box-shadow: 0 2px 5px 0 rgba(0,0,0,0.16) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a {
|
||||
border-radius: 4px !important;
|
||||
border: 1px solid #ddd !important;
|
||||
background-color: white !important;
|
||||
color: #333 !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a:hover {
|
||||
background-color: #f5f5f5 !important;
|
||||
}
|
||||
|
||||
/* Ensure map tiles load properly */
|
||||
.leaflet-container {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
/* Leaflet popup custom styling */
|
||||
.leaflet-popup-content-wrapper {
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
.leaflet-popup-content {
|
||||
margin: 8px 12px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Custom zoom control styling */
|
||||
.leaflet-control-zoom {
|
||||
border: none !important;
|
||||
border-radius: 8px !important;
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15) !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a {
|
||||
border-radius: 4px !important;
|
||||
color: #374151 !important;
|
||||
font-weight: bold !important;
|
||||
}
|
||||
|
||||
.leaflet-control-zoom a:hover {
|
||||
background-color: #f3f4f6 !important;
|
||||
color: #1f2937 !important;
|
||||
}
|
||||
|
||||
/* Scale control styling */
|
||||
.leaflet-control-scale {
|
||||
background-color: rgba(255, 255, 255, 0.9) !important;
|
||||
border-radius: 4px !important;
|
||||
padding: 2px 6px !important;
|
||||
font-size: 11px !important;
|
||||
}
|
||||
|
||||
/* Map loading indicator */
|
||||
.map-loading {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
z-index: 1000;
|
||||
background: rgba(255, 255, 255, 0.9);
|
||||
padding: 20px;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
@@ -85,62 +234,123 @@
|
||||
<!-- 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="lg:col-span-2">
|
||||
<!-- Adventure Story Blog Post -->
|
||||
<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>
|
||||
<p class="text-blue-100">Follow the journey step by step</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>
|
||||
<div class="p-8">
|
||||
<div class="prose prose-lg max-w-none">
|
||||
{% set content_sections = post.content.split('\n\n') %}
|
||||
{% set section_images = post.images.filter_by(is_cover=False).all() %}
|
||||
{% set images_per_section = (section_images|length / (content_sections|length))|round|int if content_sections|length > 0 else 0 %}
|
||||
|
||||
{% for section in content_sections %}
|
||||
{% if section.strip() %}
|
||||
<div class="mb-8 pb-6 {% if not loop.last %}border-b border-gray-200{% endif %}">
|
||||
{% set section_text = section.strip() %}
|
||||
|
||||
<!-- Extract keywords and clean text -->
|
||||
{% set keywords = [] %}
|
||||
{% set clean_text = section_text %}
|
||||
|
||||
<!-- Process **keyword** patterns and standalone keywords -->
|
||||
{% if '**' in section_text %}
|
||||
{% set parts = section_text.split('**') %}
|
||||
{% set clean_parts = [] %}
|
||||
{% for i in range(parts|length) %}
|
||||
{% if i % 2 == 1 %}
|
||||
<!-- This is a keyword between ** ** -->
|
||||
{% set _ = keywords.append(parts[i].strip()) %}
|
||||
{% else %}
|
||||
<!-- This is regular text -->
|
||||
{% set _ = clean_parts.append(parts[i]) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% set clean_text = clean_parts|join(' ')|trim %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Also extract standalone keywords (before <br><br>) -->
|
||||
{% if '<br>' in clean_text %}
|
||||
{% set text_parts = clean_text.split('<br>') %}
|
||||
{% set first_part = text_parts[0].strip() %}
|
||||
|
||||
<!-- If first part looks like keywords (short words), extract them -->
|
||||
{% if first_part and first_part|length < 100 and ' ' in first_part %}
|
||||
{% set potential_keywords = first_part.split() %}
|
||||
{% if potential_keywords|length <= 5 %}
|
||||
<!-- Likely keywords, add them and remove from text -->
|
||||
{% for kw in potential_keywords %}
|
||||
{% if kw.strip() %}
|
||||
{% set _ = keywords.append(kw.strip()) %}
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<!-- Keep only text after the first <br><br> -->
|
||||
{% set remaining_parts = text_parts[1:] %}
|
||||
{% set clean_text = remaining_parts|join('<br>')|trim %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Clean up multiple <br> tags -->
|
||||
{% set clean_text = clean_text.replace('<br><br><br>', '<br><br>').replace('<br> <br>', '<br><br>').strip() %}
|
||||
|
||||
<!-- Section Keywords -->
|
||||
{% if keywords %}
|
||||
<div class="mb-4">
|
||||
{% for keyword in keywords %}
|
||||
{% if keyword.strip() %}
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-gradient-to-r from-amber-500 to-orange-500 text-white mr-2 mb-2">
|
||||
<i class="fas fa-tag mr-1 text-xs"></i>
|
||||
{{ keyword.strip() }}
|
||||
</span>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Section Text -->
|
||||
<div class="text-gray-700 leading-relaxed mb-6 text-lg">
|
||||
{{ clean_text | safe | nl2br }}
|
||||
</div>
|
||||
|
||||
<!-- Section Images -->
|
||||
{% if section_images %}
|
||||
{% set start_idx = loop.index0 * images_per_section %}
|
||||
{% set end_idx = start_idx + images_per_section %}
|
||||
{% set current_section_images = section_images[start_idx:end_idx] %}
|
||||
|
||||
{% if current_section_images %}
|
||||
<div class="grid grid-cols-1 {% if current_section_images|length > 1 %}md:grid-cols-2{% endif %} gap-4 mb-6">
|
||||
{% for image in current_section_images %}
|
||||
<div class="relative rounded-xl overflow-hidden cursor-pointer group transition-all duration-300 hover:shadow-lg"
|
||||
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 transition-transform duration-300 group-hover:scale-105">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
{% if image.description %}
|
||||
<div class="absolute bottom-0 left-0 right-0 p-3 text-white text-sm opacity-0 group-hover:opacity-100 transition-opacity duration-300">
|
||||
{{ image.description }}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
</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">
|
||||
@@ -224,20 +434,29 @@
|
||||
|
||||
<!-- Route Statistics -->
|
||||
<div class="grid grid-cols-2 gap-4 mb-6">
|
||||
{% set gpx_file = post.gpx_files.first() %}
|
||||
<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-2xl font-bold text-blue-600" id="distance">
|
||||
{{ gpx_file.total_distance if gpx_file and gpx_file.total_distance > 0 else '-' }}
|
||||
</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 class="text-2xl font-bold text-green-600" id="elevation-gain">
|
||||
{{ gpx_file.elevation_gain|int if gpx_file and gpx_file.elevation_gain > 0 else '-' }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">Elevation Gain (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-2xl font-bold text-purple-600" id="max-elevation">
|
||||
{{ gpx_file.max_elevation|int if gpx_file and gpx_file.max_elevation > 0 else '-' }}
|
||||
</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-2xl font-bold text-orange-600" id="waypoints">
|
||||
{{ gpx_file.total_points if gpx_file and gpx_file.total_points > 0 else '-' }}
|
||||
</div>
|
||||
<div class="text-sm text-gray-600">Track Points</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -270,6 +489,46 @@
|
||||
</div>
|
||||
</div>
|
||||
</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>
|
||||
{% endif %}
|
||||
|
||||
<!-- Adventure Info -->
|
||||
@@ -319,6 +578,45 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Photo Gallery (Compact) -->
|
||||
{% 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-4">
|
||||
<div class="flex items-center text-white">
|
||||
<i class="fas fa-camera-retro text-xl mr-2"></i>
|
||||
<div>
|
||||
<h2 class="text-lg font-bold">Photo Gallery</h2>
|
||||
<p class="text-green-100 text-sm">{{ post.images.count() }} photos</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
{% for image in post.images %}
|
||||
<div class="relative rounded-lg overflow-hidden cursor-pointer group"
|
||||
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-20 object-cover transition-transform duration-300 group-hover:scale-110">
|
||||
{% if image.is_cover %}
|
||||
<div class="absolute top-1 left-1">
|
||||
<span class="inline-flex items-center px-1 py-0.5 rounded text-xs font-semibold bg-yellow-500 text-white">
|
||||
<i class="fas fa-star"></i>
|
||||
</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div class="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity duration-300"></div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if post.images.count() > 4 %}
|
||||
<div class="mt-3 text-center">
|
||||
<span class="text-sm text-gray-500">Click any photo to view gallery</span>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -326,136 +624,241 @@
|
||||
|
||||
<!-- 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 class="max-w-4xl max-h-full p-4">
|
||||
<div class="relative">
|
||||
<button onclick="closeImageModal()"
|
||||
class="absolute top-2 right-2 text-white bg-black bg-opacity-50 rounded-full w-8 h-8 flex items-center justify-center hover:bg-opacity-75 z-10">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
<img id="modalImage" src="" alt="" class="max-w-full max-h-screen object-contain rounded-lg">
|
||||
<div id="modalCaption" class="text-white text-center mt-2 px-4"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Expanded Map Modal -->
|
||||
<div id="expandedMapModal" class="fixed inset-0 bg-black bg-opacity-90 flex items-center justify-center z-50 hidden">
|
||||
<div class="w-full h-full p-4 md:p-8 flex flex-col">
|
||||
<div class="flex justify-end mb-4">
|
||||
<button onclick="closeExpandedMap()"
|
||||
class="text-white bg-black bg-opacity-50 rounded-full w-12 h-12 flex items-center justify-center hover:bg-opacity-75 transition-all duration-200">
|
||||
<i class="fas fa-times text-xl"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="flex-1 bg-white rounded-lg overflow-hidden shadow-2xl">
|
||||
<div id="expanded-map" class="w-full h-full"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
|
||||
crossorigin=""></script>
|
||||
<script>
|
||||
// Global variables
|
||||
let map;
|
||||
let interactiveMap;
|
||||
let expandedMap;
|
||||
let gpxPolyline;
|
||||
let trackPointsData = [];
|
||||
|
||||
// Simple test to check if Leaflet is loaded
|
||||
console.log('Leaflet loaded:', typeof L !== 'undefined');
|
||||
|
||||
// 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
|
||||
console.log('DOM Content Loaded - Starting simple map test...');
|
||||
|
||||
// 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 %}
|
||||
// Simple map initialization
|
||||
setTimeout(function() {
|
||||
console.log('Attempting to create basic map...');
|
||||
|
||||
try {
|
||||
// Create a very basic map
|
||||
const mapContainer = document.getElementById('interactive-map');
|
||||
if (!mapContainer) {
|
||||
console.error('Map container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('Container found, creating map...');
|
||||
|
||||
// Clear any existing content
|
||||
mapContainer.innerHTML = '';
|
||||
|
||||
// Create map with basic settings
|
||||
interactiveMap = L.map('interactive-map').setView([45.9432, 24.9668], 6);
|
||||
|
||||
console.log('Map created, adding tiles...');
|
||||
|
||||
// Add OpenStreetMap tiles
|
||||
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
|
||||
attribution: '© OpenStreetMap contributors'
|
||||
}).addTo(interactiveMap);
|
||||
|
||||
console.log('Tiles added, adding test marker...');
|
||||
|
||||
// Add a test marker
|
||||
L.marker([45.9432, 24.9668])
|
||||
.addTo(interactiveMap)
|
||||
.bindPopup('🗺️ Interactive Map Test - Romania')
|
||||
.openPopup();
|
||||
|
||||
console.log('✅ Basic map setup complete!');
|
||||
|
||||
// Hide loading indicator
|
||||
const loadingDiv = document.getElementById('map-loading');
|
||||
if (loadingDiv) {
|
||||
loadingDiv.style.display = 'none';
|
||||
}
|
||||
|
||||
// Hide fallback content
|
||||
const fallbackDiv = document.getElementById('map-fallback');
|
||||
if (fallbackDiv) {
|
||||
fallbackDiv.style.display = 'none';
|
||||
}
|
||||
|
||||
// Force resize
|
||||
setTimeout(() => {
|
||||
interactiveMap.invalidateSize();
|
||||
console.log('Map size invalidated');
|
||||
}, 500);
|
||||
|
||||
} catch (error) {
|
||||
console.error('❌ Error creating map:', error);
|
||||
|
||||
// Show error in the container
|
||||
const mapContainer = document.getElementById('interactive-map');
|
||||
if (mapContainer) {
|
||||
mapContainer.innerHTML = `
|
||||
<div class="flex items-center justify-center h-full bg-red-50 text-red-600 p-4">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-exclamation-triangle text-2xl mb-2"></i>
|
||||
<div class="font-semibold">Map Loading Error</div>
|
||||
<div class="text-sm">${error.message}</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
}
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
function loadGPXFile(gpxUrl) {
|
||||
fetch(gpxUrl)
|
||||
.then(response => response.text())
|
||||
.then(gpxContent => {
|
||||
const parser = new DOMParser();
|
||||
const gpxDoc = parser.parseFromString(gpxContent, 'application/xml');
|
||||
// Global functions that need to be available regardless of GPX files
|
||||
function expandMap() {
|
||||
console.log('Expand map button clicked');
|
||||
const modal = document.getElementById('expandedMapModal');
|
||||
if (!modal) {
|
||||
console.error('Expanded map modal not found');
|
||||
return;
|
||||
}
|
||||
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
// Initialize expanded map after modal is shown
|
||||
setTimeout(() => {
|
||||
if (!expandedMap) {
|
||||
console.log('Initializing expanded map...');
|
||||
expandedMap = L.map('expanded-map', {
|
||||
zoomControl: true,
|
||||
scrollWheelZoom: true,
|
||||
doubleClickZoom: true,
|
||||
boxZoom: true,
|
||||
dragging: true
|
||||
}).setView([45.9432, 24.9668], 6);
|
||||
|
||||
// Parse track points
|
||||
const trackPoints = [];
|
||||
const trkpts = gpxDoc.getElementsByTagName('trkpt');
|
||||
let totalDistance = 0;
|
||||
let elevationGain = 0;
|
||||
let maxElevation = 0;
|
||||
let previousPoint = null;
|
||||
// Add tile layer with scale control
|
||||
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: 2
|
||||
}).addTo(expandedMap);
|
||||
|
||||
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 scale control
|
||||
L.control.scale({
|
||||
position: 'bottomleft',
|
||||
metric: true,
|
||||
imperial: false
|
||||
}).addTo(expandedMap);
|
||||
}
|
||||
|
||||
// Clear existing layers except tile layer
|
||||
expandedMap.eachLayer(function(layer) {
|
||||
if (layer instanceof L.Polyline || layer instanceof L.Marker) {
|
||||
expandedMap.removeLayer(layer);
|
||||
}
|
||||
|
||||
// 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);
|
||||
});
|
||||
|
||||
// Add the GPX track to expanded map if we have track data
|
||||
if (trackPointsData && trackPointsData.length > 0) {
|
||||
console.log('Adding track data to expanded map...');
|
||||
|
||||
const expandedPolyline = L.polyline(trackPointsData, {
|
||||
color: '#ef4444',
|
||||
weight: 5,
|
||||
opacity: 0.9,
|
||||
smoothFactor: 1
|
||||
}).addTo(expandedMap);
|
||||
|
||||
// Fit map to track
|
||||
expandedMap.fitBounds(expandedPolyline.getBounds(), {
|
||||
padding: [30, 30],
|
||||
maxZoom: 15
|
||||
});
|
||||
|
||||
// Add enhanced markers
|
||||
const startIcon = L.divIcon({
|
||||
html: '<div class="w-8 h-8 bg-green-500 rounded-full border-2 border-white flex items-center justify-center text-white shadow-lg"><i class="fas fa-play"></i></div>',
|
||||
className: 'custom-div-icon',
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
});
|
||||
|
||||
const endIcon = L.divIcon({
|
||||
html: '<div class="w-8 h-8 bg-red-500 rounded-full border-2 border-white flex items-center justify-center text-white shadow-lg"><i class="fas fa-flag-checkered"></i></div>',
|
||||
className: 'custom-div-icon',
|
||||
iconSize: [32, 32],
|
||||
iconAnchor: [16, 16]
|
||||
});
|
||||
|
||||
L.marker(trackPointsData[0], { icon: startIcon })
|
||||
.bindPopup('<strong>🚀 Start Point</strong>')
|
||||
.addTo(expandedMap);
|
||||
|
||||
L.marker(trackPointsData[trackPointsData.length - 1], { icon: endIcon })
|
||||
.bindPopup('<strong>🏁 End Point</strong>')
|
||||
.addTo(expandedMap);
|
||||
} else {
|
||||
console.log('No track data available for expanded map');
|
||||
// Show a message on the map
|
||||
L.popup()
|
||||
.setLatLng([45.9432, 24.9668])
|
||||
.setContent('<div class="text-gray-600">📍 No GPX route data available</div>')
|
||||
.openOn(expandedMap);
|
||||
}
|
||||
|
||||
// Invalidate size to ensure proper rendering
|
||||
expandedMap.invalidateSize();
|
||||
|
||||
console.log('✓ Expanded map setup complete');
|
||||
}, 100);
|
||||
}
|
||||
|
||||
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;
|
||||
function closeExpandedMap() {
|
||||
const modal = document.getElementById('expandedMapModal');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
{% endif %}
|
||||
|
||||
// Close expanded map on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeExpandedMap();
|
||||
}
|
||||
});
|
||||
|
||||
// Image modal functionality
|
||||
function openImageModal(imageSrc, imageTitle) {
|
||||
|
||||
3
app/utils/__init__.py
Normal file
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Utility functions package
|
||||
"""
|
||||
176
app/utils/gpx_processor.py
Normal file
@@ -0,0 +1,176 @@
|
||||
"""
|
||||
GPX file processing utilities for extracting route statistics
|
||||
"""
|
||||
|
||||
import xml.etree.ElementTree as ET
|
||||
import math
|
||||
import os
|
||||
from typing import Dict, Optional, Tuple
|
||||
|
||||
|
||||
def calculate_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""
|
||||
Calculate the great circle distance between two points on the earth (specified in decimal degrees)
|
||||
Returns distance in kilometers
|
||||
"""
|
||||
# Convert decimal degrees to radians
|
||||
lat1, lon1, lat2, lon2 = map(math.radians, [lat1, lon1, lat2, lon2])
|
||||
|
||||
# Haversine formula
|
||||
dlat = lat2 - lat1
|
||||
dlon = lon2 - lon1
|
||||
a = math.sin(dlat/2)**2 + math.cos(lat1) * math.cos(lat2) * math.sin(dlon/2)**2
|
||||
c = 2 * math.asin(math.sqrt(a))
|
||||
|
||||
# Radius of earth in kilometers
|
||||
r = 6371
|
||||
|
||||
return c * r
|
||||
|
||||
|
||||
def extract_gpx_statistics(file_path: str) -> Optional[Dict]:
|
||||
"""
|
||||
Extract statistics from a GPX file
|
||||
|
||||
Returns:
|
||||
Dictionary with keys: total_distance, elevation_gain, max_elevation,
|
||||
min_elevation, total_points, or None if file cannot be processed
|
||||
"""
|
||||
if not os.path.exists(file_path):
|
||||
return None
|
||||
|
||||
try:
|
||||
# Parse GPX file
|
||||
tree = ET.parse(file_path)
|
||||
root = tree.getroot()
|
||||
|
||||
# Handle GPX namespace
|
||||
namespace = {'gpx': 'http://www.topografix.com/GPX/1/1'}
|
||||
if not root.tag.endswith('gpx'):
|
||||
# Try without namespace
|
||||
namespace = {}
|
||||
|
||||
# Find track points
|
||||
track_points = []
|
||||
|
||||
# Look for track points in tracks
|
||||
tracks = root.findall('.//gpx:trk', namespace) if namespace else root.findall('.//trk')
|
||||
for track in tracks:
|
||||
segments = track.findall('.//gpx:trkseg', namespace) if namespace else track.findall('.//trkseg')
|
||||
for segment in segments:
|
||||
points = segment.findall('.//gpx:trkpt', namespace) if namespace else segment.findall('.//trkpt')
|
||||
for point in points:
|
||||
lat = float(point.get('lat'))
|
||||
lon = float(point.get('lon'))
|
||||
|
||||
# Get elevation if available
|
||||
ele_elem = point.find('gpx:ele', namespace) if namespace else point.find('ele')
|
||||
elevation = float(ele_elem.text) if ele_elem is not None and ele_elem.text else 0.0
|
||||
|
||||
track_points.append({
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'elevation': elevation
|
||||
})
|
||||
|
||||
# Also look for waypoints if no track points found
|
||||
if not track_points:
|
||||
waypoints = root.findall('.//gpx:wpt', namespace) if namespace else root.findall('.//wpt')
|
||||
for point in waypoints:
|
||||
lat = float(point.get('lat'))
|
||||
lon = float(point.get('lon'))
|
||||
|
||||
ele_elem = point.find('gpx:ele', namespace) if namespace else point.find('ele')
|
||||
elevation = float(ele_elem.text) if ele_elem is not None and ele_elem.text else 0.0
|
||||
|
||||
track_points.append({
|
||||
'lat': lat,
|
||||
'lon': lon,
|
||||
'elevation': elevation
|
||||
})
|
||||
|
||||
if not track_points:
|
||||
return {
|
||||
'total_distance': 0.0,
|
||||
'elevation_gain': 0.0,
|
||||
'max_elevation': 0.0,
|
||||
'min_elevation': 0.0,
|
||||
'total_points': 0
|
||||
}
|
||||
|
||||
# Calculate statistics
|
||||
total_distance = 0.0
|
||||
elevation_gain = 0.0
|
||||
elevations = [point['elevation'] for point in track_points if point['elevation'] > 0]
|
||||
|
||||
# Calculate distance and elevation gain
|
||||
for i in range(1, len(track_points)):
|
||||
current = track_points[i]
|
||||
previous = track_points[i-1]
|
||||
|
||||
# Distance
|
||||
distance = calculate_distance(
|
||||
previous['lat'], previous['lon'],
|
||||
current['lat'], current['lon']
|
||||
)
|
||||
total_distance += distance
|
||||
|
||||
# Elevation gain (only uphill)
|
||||
if current['elevation'] > 0 and previous['elevation'] > 0:
|
||||
elevation_diff = current['elevation'] - previous['elevation']
|
||||
if elevation_diff > 0:
|
||||
elevation_gain += elevation_diff
|
||||
|
||||
# Elevation statistics
|
||||
max_elevation = max(elevations) if elevations else 0.0
|
||||
min_elevation = min(elevations) if elevations else 0.0
|
||||
|
||||
return {
|
||||
'total_distance': round(total_distance, 2),
|
||||
'elevation_gain': round(elevation_gain, 1),
|
||||
'max_elevation': round(max_elevation, 1),
|
||||
'min_elevation': round(min_elevation, 1),
|
||||
'total_points': len(track_points)
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error processing GPX file {file_path}: {e}")
|
||||
return None
|
||||
|
||||
|
||||
def process_gpx_file(gpx_file_record) -> bool:
|
||||
"""
|
||||
Process a GPXFile record and update its statistics
|
||||
|
||||
Args:
|
||||
gpx_file_record: GPXFile model instance
|
||||
|
||||
Returns:
|
||||
True if processing was successful, False otherwise
|
||||
"""
|
||||
from flask import current_app
|
||||
|
||||
# Build file path
|
||||
if gpx_file_record.post.media_folder:
|
||||
file_path = os.path.join(
|
||||
current_app.root_path, 'static', 'media', 'posts',
|
||||
gpx_file_record.post.media_folder, 'gpx', gpx_file_record.filename
|
||||
)
|
||||
else:
|
||||
file_path = os.path.join(
|
||||
current_app.root_path, 'static', 'uploads', 'gpx', gpx_file_record.filename
|
||||
)
|
||||
|
||||
# Extract statistics
|
||||
stats = extract_gpx_statistics(file_path)
|
||||
if stats is None:
|
||||
return False
|
||||
|
||||
# Update the record
|
||||
gpx_file_record.total_distance = stats['total_distance']
|
||||
gpx_file_record.elevation_gain = stats['elevation_gain']
|
||||
gpx_file_record.max_elevation = stats['max_elevation']
|
||||
gpx_file_record.min_elevation = stats['min_elevation']
|
||||
gpx_file_record.total_points = stats['total_points']
|
||||
|
||||
return True
|
||||
41
run.py
@@ -75,11 +75,52 @@ def migrate_db():
|
||||
else:
|
||||
print('page_views table already exists')
|
||||
|
||||
# Check if GPX statistics columns exist
|
||||
result = db.session.execute(text('PRAGMA table_info(gpx_files)'))
|
||||
columns = [row[1] for row in result.fetchall()]
|
||||
|
||||
new_columns = [
|
||||
('total_distance', 'REAL DEFAULT 0.0'),
|
||||
('elevation_gain', 'REAL DEFAULT 0.0'),
|
||||
('max_elevation', 'REAL DEFAULT 0.0'),
|
||||
('min_elevation', 'REAL DEFAULT 0.0'),
|
||||
('total_points', 'INTEGER DEFAULT 0')
|
||||
]
|
||||
|
||||
for column_name, column_type in new_columns:
|
||||
if column_name not in columns:
|
||||
db.session.execute(text(f'ALTER TABLE gpx_files ADD COLUMN {column_name} {column_type}'))
|
||||
db.session.commit()
|
||||
print(f'Successfully added {column_name} column to gpx_files table')
|
||||
else:
|
||||
print(f'{column_name} column already exists in gpx_files table')
|
||||
|
||||
print('Database schema is up to date')
|
||||
except Exception as e:
|
||||
print(f'Migration error: {e}')
|
||||
db.session.rollback()
|
||||
|
||||
@app.cli.command()
|
||||
def process_gpx_files():
|
||||
"""Process existing GPX files to extract statistics."""
|
||||
from app.utils.gpx_processor import process_gpx_file
|
||||
|
||||
gpx_files = GPXFile.query.all()
|
||||
processed = 0
|
||||
|
||||
for gpx_file in gpx_files:
|
||||
try:
|
||||
if process_gpx_file(gpx_file):
|
||||
processed += 1
|
||||
print(f'Processed: {gpx_file.original_name}')
|
||||
else:
|
||||
print(f'Failed to process: {gpx_file.original_name}')
|
||||
except Exception as e:
|
||||
print(f'Error processing {gpx_file.original_name}: {e}')
|
||||
|
||||
db.session.commit()
|
||||
print(f'Processed {processed}/{len(gpx_files)} GPX files')
|
||||
|
||||
@app.cli.command()
|
||||
def create_admin():
|
||||
"""Create an admin user."""
|
||||
|
||||