Major UI/UX redesign and feature enhancements
🎨 Complete Tailwind CSS conversion - Redesigned post detail page with modern gradient backgrounds - Updated profile page with consistent design language - Converted from Bootstrap to Tailwind CSS throughout ✨ New Features & Improvements - Enhanced community post management system - Added admin panel with analytics dashboard - Improved post creation and editing workflows - Interactive GPS map integration with Leaflet.js - Photo gallery with modal view and hover effects - Adventure statistics and metadata display - Like system and community engagement features 🔧 Technical Improvements - Fixed template syntax errors and CSRF token issues - Updated database models and relationships - Enhanced media file management - Improved responsive design patterns - Added proper error handling and validation 📱 Mobile-First Design - Responsive grid layouts - Touch-friendly interactions - Optimized for all screen sizes - Modern card-based UI components 🏍️ Adventure Platform Features - GPS track visualization and statistics - Photo uploads with thumbnail generation - GPX file downloads for registered users - Community comments and discussions - Post approval workflow for admins - Difficulty rating system with star indicators
This commit is contained in:
204
app/templates/admin/analytics.html
Normal file
204
app/templates/admin/analytics.html
Normal file
@@ -0,0 +1,204 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Analytics - Admin Dashboard{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div class="d-sm-flex align-items-center justify-content-between mb-4">
|
||||
<h1 class="h3 mb-0 text-gray-800">Analytics</h1>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards Row -->
|
||||
<div class="row">
|
||||
<!-- Total Page Views Card -->
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card border-left-primary shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-primary text-uppercase mb-1">
|
||||
Total Page Views</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ total_views }}</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-eye fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Unique Visitors Card -->
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card border-left-success shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-success text-uppercase mb-1">
|
||||
Unique Visitors</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ unique_visitors }}</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-users fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Today's Views Card -->
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card border-left-info shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-info text-uppercase mb-1">
|
||||
Today's Views</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ today_views }}</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-calendar-day fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- This Week Views Card -->
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card border-left-warning shadow h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col mr-2">
|
||||
<div class="text-xs font-weight-bold text-warning text-uppercase mb-1">
|
||||
This Week</div>
|
||||
<div class="h5 mb-0 font-weight-bold text-gray-800">{{ week_views }}</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-chart-line fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Popular Pages -->
|
||||
<div class="row">
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">Popular Pages</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if popular_pages %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Page</th>
|
||||
<th>Views</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for page in popular_pages %}
|
||||
<tr>
|
||||
<td>{{ page.path }}</td>
|
||||
<td><span class="badge bg-primary">{{ page.view_count }}</span></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No page view data available yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activity -->
|
||||
<div class="col-lg-6">
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">Recent Activity</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if recent_views %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-borderless">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Time</th>
|
||||
<th>Page</th>
|
||||
<th>User</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for view in recent_views %}
|
||||
<tr>
|
||||
<td class="text-xs">{{ view.created_at.strftime('%H:%M') }}</td>
|
||||
<td class="text-xs">{{ view.path }}</td>
|
||||
<td class="text-xs">
|
||||
{% if view.user %}
|
||||
{{ view.user.nickname }}
|
||||
{% else %}
|
||||
Anonymous
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No recent activity data available yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Browser Stats -->
|
||||
<div class="row">
|
||||
<div class="col-lg-12">
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">Browser Statistics</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if browser_stats %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Browser</th>
|
||||
<th>Views</th>
|
||||
<th>Percentage</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for stat in browser_stats %}
|
||||
<tr>
|
||||
<td>{{ stat.browser or 'Unknown' }}</td>
|
||||
<td>{{ stat.view_count }}</td>
|
||||
<td>
|
||||
<div class="progress">
|
||||
<div class="progress-bar" role="progressbar"
|
||||
style="width: {{ (stat.view_count / total_views * 100) if total_views > 0 else 0 }}%">
|
||||
{{ "%.1f"|format((stat.view_count / total_views * 100) if total_views > 0 else 0) }}%
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">No browser data available yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
233
app/templates/admin/base.html
Normal file
233
app/templates/admin/base.html
Normal file
@@ -0,0 +1,233 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Admin Dashboard - Moto Adventure{% endblock %}</title>
|
||||
<link rel="icon" type="image/x-icon" href="{{ url_for('static', filename='favicon.ico') }}">
|
||||
|
||||
<!-- Bootstrap CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<!-- Font Awesome -->
|
||||
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
|
||||
|
||||
<style>
|
||||
body {
|
||||
font-size: 0.875rem;
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
z-index: 100;
|
||||
padding: 48px 0 0;
|
||||
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
|
||||
background-color: #343a40;
|
||||
width: 240px;
|
||||
}
|
||||
|
||||
.sidebar-sticky {
|
||||
position: relative;
|
||||
top: 0;
|
||||
height: calc(100vh - 48px);
|
||||
padding-top: .5rem;
|
||||
overflow-x: hidden;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.sidebar .nav-link {
|
||||
color: rgba(255, 255, 255, .75);
|
||||
padding: 10px 20px;
|
||||
border-radius: 0;
|
||||
}
|
||||
|
||||
.sidebar .nav-link:hover {
|
||||
color: #fff;
|
||||
background-color: rgba(255, 255, 255, .1);
|
||||
}
|
||||
|
||||
.sidebar .nav-link.active {
|
||||
color: #fff;
|
||||
background-color: #007bff;
|
||||
}
|
||||
|
||||
.sidebar-heading {
|
||||
font-size: .75rem;
|
||||
text-transform: uppercase;
|
||||
color: rgba(255, 255, 255, .5);
|
||||
padding: 15px 20px 5px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 240px;
|
||||
padding: 20px;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.navbar-brand {
|
||||
color: #fff !important;
|
||||
background-color: #007bff;
|
||||
padding: 10px 20px;
|
||||
margin: 0;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.card {
|
||||
border: none;
|
||||
box-shadow: 0 0.15rem 1.75rem 0 rgba(33, 40, 50, 0.15);
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid rgba(33, 40, 50, 0.125);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.border-left-primary {
|
||||
border-left: 0.25rem solid #4e73df !important;
|
||||
}
|
||||
|
||||
.border-left-success {
|
||||
border-left: 0.25rem solid #1cc88a !important;
|
||||
}
|
||||
|
||||
.border-left-warning {
|
||||
border-left: 0.25rem solid #f6c23e !important;
|
||||
}
|
||||
|
||||
.border-left-info {
|
||||
border-left: 0.25rem solid #36b9cc !important;
|
||||
}
|
||||
|
||||
.text-xs {
|
||||
font-size: 0.7rem;
|
||||
}
|
||||
|
||||
.text-gray-800 {
|
||||
color: #5a5c69 !important;
|
||||
}
|
||||
|
||||
.text-gray-300 {
|
||||
color: #dddfeb !important;
|
||||
}
|
||||
|
||||
.table th {
|
||||
border-top: none;
|
||||
font-weight: 600;
|
||||
font-size: 0.8rem;
|
||||
color: #5a5c69;
|
||||
background-color: #f8f9fc;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.sidebar {
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
.sidebar.show {
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
.main-content {
|
||||
margin-left: 0;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Sidebar -->
|
||||
<nav class="sidebar" id="sidebar">
|
||||
<div class="navbar-brand">
|
||||
<i class="fas fa-cogs"></i> Admin Panel
|
||||
</div>
|
||||
<div class="sidebar-sticky">
|
||||
<div class="sidebar-heading">
|
||||
Administration
|
||||
</div>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'admin.dashboard' }}" href="{{ url_for('admin.dashboard') }}">
|
||||
<i class="fas fa-tachometer-alt"></i> Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'admin.posts' }}" href="{{ url_for('admin.posts') }}">
|
||||
<i class="fas fa-file-alt"></i> Posts
|
||||
{% if pending_posts_count %}
|
||||
<span class="badge bg-warning text-dark ms-2">{{ pending_posts_count }}</span>
|
||||
{% endif %}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'admin.users' }}" href="{{ url_for('admin.users') }}">
|
||||
<i class="fas fa-users"></i> Users
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link {{ 'active' if request.endpoint == 'admin.analytics' }}" href="{{ url_for('admin.analytics') }}">
|
||||
<i class="fas fa-chart-bar"></i> Analytics
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div class="sidebar-heading">
|
||||
Quick Actions
|
||||
</div>
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('community.index') }}" target="_blank">
|
||||
<i class="fas fa-external-link-alt"></i> View Site
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin.posts', status='pending') }}">
|
||||
<i class="fas fa-clock"></i> Pending Posts
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="main-content">
|
||||
<!-- Mobile menu button -->
|
||||
<button class="btn btn-primary d-md-none mb-3" type="button" id="sidebarToggle">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
|
||||
<!-- Flash Messages -->
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ 'danger' if category == 'error' else category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
{% block admin_content %}{% endblock %}
|
||||
</div>
|
||||
|
||||
<!-- Bootstrap JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
|
||||
<script>
|
||||
// Mobile sidebar toggle
|
||||
document.getElementById('sidebarToggle')?.addEventListener('click', function() {
|
||||
document.getElementById('sidebar').classList.toggle('show');
|
||||
});
|
||||
|
||||
// Auto-refresh stats every 30 seconds
|
||||
setTimeout(function() {
|
||||
location.reload();
|
||||
}, 30000);
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
247
app/templates/admin/dashboard.html
Normal file
247
app/templates/admin/dashboard.html
Normal file
@@ -0,0 +1,247 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Admin Dashboard - Moto Adventure{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">Dashboard</h1>
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="location.reload()">
|
||||
<i class="fas fa-sync-alt"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Statistics Cards -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card border-left-primary h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col me-2">
|
||||
<div class="text-xs fw-bold text-primary text-uppercase mb-1">Total Users</div>
|
||||
<div class="h5 mb-0 fw-bold text-gray-800">{{ total_users }}</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-users fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card border-left-success h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col me-2">
|
||||
<div class="text-xs fw-bold text-success text-uppercase mb-1">Published Posts</div>
|
||||
<div class="h5 mb-0 fw-bold text-gray-800">{{ published_posts }}</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-check fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card border-left-warning h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col me-2">
|
||||
<div class="text-xs fw-bold text-warning text-uppercase mb-1">Pending Posts</div>
|
||||
<div class="h5 mb-0 fw-bold text-gray-800">
|
||||
<a href="{{ url_for('admin.posts', status='pending') }}" class="text-decoration-none text-dark">
|
||||
{{ pending_posts }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-clock fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-3 col-md-6 mb-4">
|
||||
<div class="card border-left-info h-100 py-2">
|
||||
<div class="card-body">
|
||||
<div class="row no-gutters align-items-center">
|
||||
<div class="col me-2">
|
||||
<div class="text-xs fw-bold text-info text-uppercase mb-1">Active Users (30d)</div>
|
||||
<div class="h5 mb-0 fw-bold text-gray-800">{{ active_users }}</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<i class="fas fa-user-check fa-2x text-gray-300"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Views Statistics -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-xl-4 col-md-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 fw-bold text-primary">Views Today</h6>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<div class="h3 mb-0 text-gray-800">{{ views_today }}</div>
|
||||
<small class="text-muted">Yesterday: {{ views_yesterday }}</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-4 col-md-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 fw-bold text-primary">Views This Week</h6>
|
||||
</div>
|
||||
<div class="card-body text-center">
|
||||
<div class="h3 mb-0 text-gray-800">{{ views_this_week }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-xl-4 col-md-6 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 fw-bold text-primary">Total Engagement</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="small mb-1">Comments: <span class="float-end">{{ total_comments }}</span></div>
|
||||
<div class="small mb-1">Likes: <span class="float-end">{{ total_likes }}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Overview -->
|
||||
<div class="row">
|
||||
<!-- Recent Posts -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
|
||||
<h6 class="m-0 fw-bold text-primary">Recent Posts</h6>
|
||||
<a href="{{ url_for('admin.posts') }}" class="btn btn-sm btn-primary">View All</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if recent_posts %}
|
||||
{% for post in recent_posts %}
|
||||
<div class="d-flex align-items-center mb-3 border-bottom pb-2">
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1">
|
||||
<a href="{{ url_for('admin.post_detail', post_id=post.id) }}" class="text-decoration-none">
|
||||
{{ post.title[:50] }}{% if post.title|length > 50 %}...{% endif %}
|
||||
</a>
|
||||
</h6>
|
||||
<small class="text-muted">
|
||||
by {{ post.author.nickname }} • {{ post.created_at.strftime('%Y-%m-%d %H:%M') }}
|
||||
</small>
|
||||
</div>
|
||||
<div class="ms-2">
|
||||
{% if post.published %}
|
||||
<span class="badge bg-success">Published</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">Pending</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted">No recent posts</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Most Viewed Posts -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 fw-bold text-primary">Most Viewed Posts</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if most_viewed_posts %}
|
||||
{% for post_id, title, view_count in most_viewed_posts %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-2 border-bottom pb-1">
|
||||
<div class="flex-grow-1">
|
||||
<a href="{{ url_for('admin.post_detail', post_id=post_id) }}" class="text-decoration-none">
|
||||
{{ title[:40] }}{% if title|length > 40 %}...{% endif %}
|
||||
</a>
|
||||
</div>
|
||||
<span class="badge bg-info">{{ view_count }} views</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted">No view data available</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Bottom Row -->
|
||||
<div class="row">
|
||||
<!-- Most Viewed Pages -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 fw-bold text-primary">Most Viewed Pages</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if most_viewed_pages %}
|
||||
{% for path, view_count in most_viewed_pages %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<code class="small">{{ path }}</code>
|
||||
<span class="badge bg-secondary">{{ view_count }}</span>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted">No page view data available</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Users -->
|
||||
<div class="col-lg-6 mb-4">
|
||||
<div class="card">
|
||||
<div class="card-header py-3 d-flex flex-row align-items-center justify-content-between">
|
||||
<h6 class="m-0 fw-bold text-primary">Recent Users</h6>
|
||||
<a href="{{ url_for('admin.users') }}" class="btn btn-sm btn-primary">View All</a>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if recent_users %}
|
||||
{% for user in recent_users %}
|
||||
<div class="d-flex align-items-center mb-3 border-bottom pb-2">
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1">
|
||||
<a href="{{ url_for('admin.user_detail', user_id=user.id) }}" class="text-decoration-none">
|
||||
{{ user.nickname }}
|
||||
</a>
|
||||
</h6>
|
||||
<small class="text-muted">{{ user.email }} • {{ user.created_at.strftime('%Y-%m-%d') }}</small>
|
||||
</div>
|
||||
<div class="ms-2">
|
||||
{% if user.is_admin %}
|
||||
<span class="badge bg-danger">Admin</span>
|
||||
{% else %}
|
||||
<span class="badge bg-primary">User</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<p class="text-muted">No recent users</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
235
app/templates/admin/post_detail.html
Normal file
235
app/templates/admin/post_detail.html
Normal file
@@ -0,0 +1,235 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}{{ post.title }} - Post Review{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">Post Review</h1>
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<div class="btn-group me-2">
|
||||
<a href="{{ url_for('admin.posts') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Posts
|
||||
</a>
|
||||
</div>
|
||||
<div class="btn-group">
|
||||
{% if not post.published %}
|
||||
<form method="POST" action="{{ url_for('admin.publish_post', post_id=post.id) }}" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-success" onclick="return confirm('Publish this post?')">
|
||||
<i class="fas fa-check"></i> Publish
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="POST" action="{{ url_for('admin.unpublish_post', post_id=post.id) }}" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-warning" onclick="return confirm('Unpublish this post?')">
|
||||
<i class="fas fa-times"></i> Unpublish
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('admin.delete_post', post_id=post.id) }}" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-danger"
|
||||
onclick="return confirm('Are you sure you want to delete this post? This action cannot be undone.')">
|
||||
<i class="fas fa-trash"></i> Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- Post Details -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 fw-bold text-primary">Post Content</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>{{ post.title }}</h3>
|
||||
{% if post.subtitle %}
|
||||
<h5 class="text-muted mb-3">{{ post.subtitle }}</h5>
|
||||
{% endif %}
|
||||
|
||||
<div class="mb-3">
|
||||
<span class="badge bg-info">{{ post.get_difficulty_label() }}</span>
|
||||
{% if post.published %}
|
||||
<span class="badge bg-success">Published</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">Pending Review</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="post-content">
|
||||
{{ post.content|replace('\n', '<br>')|safe }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post Images -->
|
||||
{% if post.images.count() > 0 %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 fw-bold text-primary">Images ({{ post.images.count() }})</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
{% for image in post.images %}
|
||||
<div class="col-md-4 mb-3">
|
||||
<div class="card">
|
||||
<img src="{{ image.get_url() }}" class="card-img-top" alt="{{ image.original_name }}"
|
||||
style="height: 200px; object-fit: cover;">
|
||||
<div class="card-body p-2">
|
||||
<small class="text-muted">{{ image.original_name }}</small>
|
||||
{% if image.is_cover %}
|
||||
<span class="badge bg-primary badge-sm">Cover</span>
|
||||
{% endif %}
|
||||
{% if image.description %}
|
||||
<p class="card-text small">{{ image.description }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- GPX Files -->
|
||||
{% if post.gpx_files.count() > 0 %}
|
||||
<div class="card mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 fw-bold text-primary">GPX Files ({{ post.gpx_files.count() }})</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% for gpx_file in post.gpx_files %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
||||
<div>
|
||||
<i class="fas fa-route text-primary"></i>
|
||||
<strong>{{ gpx_file.original_name }}</strong>
|
||||
<small class="text-muted">({{ "%.1f"|format(gpx_file.size / 1024) }} KB)</small>
|
||||
</div>
|
||||
<a href="{{ gpx_file.get_url() }}" class="btn btn-sm btn-outline-primary" download>
|
||||
<i class="fas fa-download"></i> Download
|
||||
</a>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Sidebar -->
|
||||
<div class="col-lg-4">
|
||||
<!-- Post Metadata -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 fw-bold text-primary">Post Information</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
<tr>
|
||||
<td><strong>Author:</strong></td>
|
||||
<td>
|
||||
<a href="{{ url_for('admin.user_detail', user_id=post.author.id) }}">
|
||||
{{ post.author.nickname }}
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Created:</strong></td>
|
||||
<td><small>{{ post.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</small></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Updated:</strong></td>
|
||||
<td><small>{{ post.updated_at.strftime('%Y-%m-%d %H:%M:%S') }}</small></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Difficulty:</strong></td>
|
||||
<td>{{ post.get_difficulty_label() }} ({{ post.difficulty }}/5)</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Status:</strong></td>
|
||||
<td>
|
||||
{% if post.published %}
|
||||
<span class="badge bg-success">Published</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">Pending</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Media Folder:</strong></td>
|
||||
<td>
|
||||
{% if post.media_folder %}
|
||||
<code>{{ post.media_folder }}</code>
|
||||
{% else %}
|
||||
<em class="text-muted">None</em>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Post Statistics -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 fw-bold text-primary">Statistics</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm table-borderless mb-0">
|
||||
<tr>
|
||||
<td><strong>Comments:</strong></td>
|
||||
<td>{{ post.comments.count() }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Likes:</strong></td>
|
||||
<td>{{ post.get_like_count() }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Images:</strong></td>
|
||||
<td>{{ post.images.count() }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>GPX Files:</strong></td>
|
||||
<td>{{ post.gpx_files.count() }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Views:</strong></td>
|
||||
<td>{{ post.page_views|length if post.page_views else 0 }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Quick Actions -->
|
||||
<div class="card">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 fw-bold text-primary">Quick Actions</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if post.published %}
|
||||
<a href="{{ url_for('community.post_detail', post_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>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('admin.user_detail', user_id=post.author.id) }}"
|
||||
class="btn btn-sm btn-info w-100">
|
||||
<i class="fas fa-user"></i> View Author
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.post-content {
|
||||
line-height: 1.6;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.badge-sm {
|
||||
font-size: 0.65em;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
176
app/templates/admin/posts.html
Normal file
176
app/templates/admin/posts.html
Normal file
@@ -0,0 +1,176 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}Post Management - Admin{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">Posts</h1>
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<div class="btn-group me-2">
|
||||
<a href="{{ url_for('admin.posts', status='all') }}"
|
||||
class="btn btn-sm {{ 'btn-primary' if status == 'all' else 'btn-outline-secondary' }}">
|
||||
All Posts
|
||||
</a>
|
||||
<a href="{{ url_for('admin.posts', status='published') }}"
|
||||
class="btn btn-sm {{ 'btn-primary' if status == 'published' else 'btn-outline-secondary' }}">
|
||||
Published
|
||||
</a>
|
||||
<a href="{{ url_for('admin.posts', status='pending') }}"
|
||||
class="btn btn-sm {{ 'btn-primary' if status == 'pending' else 'btn-outline-secondary' }}">
|
||||
Pending Review
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if posts.items %}
|
||||
<div class="card">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 fw-bold text-primary">
|
||||
{% if status == 'pending' %}
|
||||
Posts Pending Review ({{ posts.total }})
|
||||
{% elif status == 'published' %}
|
||||
Published Posts ({{ posts.total }})
|
||||
{% else %}
|
||||
All Posts ({{ posts.total }})
|
||||
{% endif %}
|
||||
</h6>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 40%;">Title</th>
|
||||
<th style="width: 15%;">Author</th>
|
||||
<th style="width: 10%;">Status</th>
|
||||
<th style="width: 10%;">Difficulty</th>
|
||||
<th style="width: 15%;">Created</th>
|
||||
<th style="width: 10%;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for post in posts.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<div>
|
||||
<a href="{{ url_for('admin.post_detail', post_id=post.id) }}" class="text-decoration-none fw-bold">
|
||||
{{ post.title }}
|
||||
</a>
|
||||
{% if post.subtitle %}
|
||||
<br><small class="text-muted">{{ post.subtitle[:80] }}{% if post.subtitle|length > 80 %}...{% endif %}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('admin.user_detail', user_id=post.author.id) }}" class="text-decoration-none">
|
||||
{{ post.author.nickname }}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if post.published %}
|
||||
<span class="badge bg-success">Published</span>
|
||||
{% else %}
|
||||
<span class="badge bg-warning text-dark">Pending</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-info">{{ post.get_difficulty_label() }}</span>
|
||||
</td>
|
||||
<td>
|
||||
<small>{{ post.created_at.strftime('%Y-%m-%d<br>%H:%M')|safe }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('admin.post_detail', post_id=post.id) }}"
|
||||
class="btn btn-sm btn-outline-info" title="View Details">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
|
||||
{% if not post.published %}
|
||||
<form method="POST" action="{{ url_for('admin.publish_post', post_id=post.id) }}" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-success" title="Publish"
|
||||
onclick="return confirm('Publish this post?')">
|
||||
<i class="fas fa-check"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<form method="POST" action="{{ url_for('admin.unpublish_post', post_id=post.id) }}" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-warning" title="Unpublish"
|
||||
onclick="return confirm('Unpublish this post?')">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
<form method="POST" action="{{ url_for('admin.delete_post', post_id=post.id) }}" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-danger" title="Delete"
|
||||
onclick="return confirm('Are you sure you want to delete this post? This action cannot be undone.')">
|
||||
<i class="fas fa-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if posts.pages > 1 %}
|
||||
<nav aria-label="Posts pagination" class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if posts.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.posts', page=posts.prev_num, status=status) }}">Previous</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in posts.iter_pages() %}
|
||||
{% if page_num %}
|
||||
{% if page_num != posts.page %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.posts', page=page_num, status=status) }}">{{ page_num }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ page_num }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">…</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if posts.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.posts', page=posts.next_num, status=status) }}">Next</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="fas fa-file-alt fa-3x text-muted mb-3"></i>
|
||||
<h5 class="text-muted">No posts found</h5>
|
||||
<p class="text-muted">
|
||||
{% if status == 'pending' %}
|
||||
There are no posts waiting for review.
|
||||
{% elif status == 'published' %}
|
||||
No posts have been published yet.
|
||||
{% else %}
|
||||
No posts have been created yet.
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
185
app/templates/admin/user_detail.html
Normal file
185
app/templates/admin/user_detail.html
Normal file
@@ -0,0 +1,185 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}{{ user.nickname }} - User Details{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pt-3 pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">User Details</h1>
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<div class="btn-group mr-2">
|
||||
<a href="{{ url_for('admin.users') }}" class="btn btn-sm btn-outline-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to Users
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<!-- User Information -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">User Information</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm table-borderless">
|
||||
<tr>
|
||||
<td><strong>Nickname:</strong></td>
|
||||
<td>{{ user.nickname }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Email:</strong></td>
|
||||
<td>{{ user.email }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Role:</strong></td>
|
||||
<td>
|
||||
{% if user.is_admin %}
|
||||
<span class="badge badge-danger">Admin</span>
|
||||
{% else %}
|
||||
<span class="badge badge-primary">User</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Status:</strong></td>
|
||||
<td>
|
||||
{% if user.is_active %}
|
||||
<span class="badge badge-success">Active</span>
|
||||
{% else %}
|
||||
<span class="badge badge-secondary">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Joined:</strong></td>
|
||||
<td>{{ user.created_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Last Updated:</strong></td>
|
||||
<td>{{ user.updated_at.strftime('%Y-%m-%d %H:%M:%S') }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Statistics -->
|
||||
<div class="card shadow">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">Statistics</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-sm table-borderless">
|
||||
<tr>
|
||||
<td><strong>Total Posts:</strong></td>
|
||||
<td>{{ user_posts|length }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Published Posts:</strong></td>
|
||||
<td>{{ user_posts|selectattr('published')|list|length }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Pending Posts:</strong></td>
|
||||
<td>{{ user_posts|rejectattr('published')|list|length }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Comments:</strong></td>
|
||||
<td>{{ user_comments|length }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td><strong>Likes Given:</strong></td>
|
||||
<td>{{ user.likes.count() }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Content -->
|
||||
<div class="col-lg-8">
|
||||
<!-- User Posts -->
|
||||
<div class="card shadow mb-4">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">Posts ({{ user_posts|length }})</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if user_posts %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-sm table-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Title</th>
|
||||
<th>Status</th>
|
||||
<th>Created</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for post in user_posts %}
|
||||
<tr>
|
||||
<td>
|
||||
<a href="{{ url_for('admin.post_detail', post_id=post.id) }}" class="text-decoration-none">
|
||||
{{ post.title[:50] }}{% if post.title|length > 50 %}...{% endif %}
|
||||
</a>
|
||||
</td>
|
||||
<td>
|
||||
{% if post.published %}
|
||||
<span class="badge badge-success">Published</span>
|
||||
{% else %}
|
||||
<span class="badge badge-warning">Pending</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<small>{{ post.created_at.strftime('%Y-%m-%d') }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<a href="{{ url_for('admin.post_detail', post_id=post.id) }}"
|
||||
class="btn btn-sm btn-outline-info">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="text-muted">This user has not created any posts yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Comments -->
|
||||
<div class="card shadow">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 font-weight-bold text-primary">Recent Comments ({{ user_comments|length }})</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if user_comments %}
|
||||
{% for comment in user_comments[:10] %}
|
||||
<div class="border-bottom mb-3 pb-3">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<p class="mb-1">{{ comment.content[:200] }}{% if comment.content|length > 200 %}...{% endif %}</p>
|
||||
<small class="text-muted">
|
||||
On post:
|
||||
<a href="{{ url_for('admin.post_detail', post_id=comment.post.id) }}">
|
||||
{{ comment.post.title }}
|
||||
</a>
|
||||
</small>
|
||||
</div>
|
||||
<small class="text-muted">{{ comment.created_at.strftime('%Y-%m-%d') }}</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if user_comments|length > 10 %}
|
||||
<p class="text-muted text-center">... and {{ user_comments|length - 10 }} more comments</p>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<p class="text-muted">This user has not made any comments yet.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
137
app/templates/admin/users.html
Normal file
137
app/templates/admin/users.html
Normal file
@@ -0,0 +1,137 @@
|
||||
{% extends "admin/base.html" %}
|
||||
|
||||
{% block title %}User Management - Admin{% endblock %}
|
||||
|
||||
{% block admin_content %}
|
||||
<div class="d-flex justify-content-between flex-wrap flex-md-nowrap align-items-center pb-2 mb-3 border-bottom">
|
||||
<h1 class="h2">Users</h1>
|
||||
<div class="btn-toolbar mb-2 mb-md-0">
|
||||
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="location.reload()">
|
||||
<i class="fas fa-sync-alt"></i> Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if users.items %}
|
||||
<div class="card">
|
||||
<div class="card-header py-3">
|
||||
<h6 class="m-0 fw-bold text-primary">Users ({{ users.total }})</h6>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 20%;">User</th>
|
||||
<th style="width: 25%;">Email</th>
|
||||
<th style="width: 10%;">Role</th>
|
||||
<th style="width: 15%;">Posts</th>
|
||||
<th style="width: 10%;">Status</th>
|
||||
<th style="width: 10%;">Joined</th>
|
||||
<th style="width: 10%;">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users.items %}
|
||||
<tr>
|
||||
<td>
|
||||
<div>
|
||||
<a href="{{ url_for('admin.user_detail', user_id=user.id) }}" class="text-decoration-none fw-bold">
|
||||
{{ user.nickname }}
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<small>{{ user.email }}</small>
|
||||
</td>
|
||||
<td>
|
||||
{% if user.is_admin %}
|
||||
<span class="badge bg-danger">Admin</span>
|
||||
{% else %}
|
||||
<span class="badge bg-primary">User</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge bg-secondary">{{ user.posts.count() }}</span>
|
||||
{% if user.posts.filter_by(published=True).count() > 0 %}
|
||||
<br><small class="text-success">({{ user.posts.filter_by(published=True).count() }} published)</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if user.is_active %}
|
||||
<span class="badge bg-success">Active</span>
|
||||
{% else %}
|
||||
<span class="badge bg-secondary">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<small>{{ user.created_at.strftime('%Y-%m-%d') }}</small>
|
||||
</td>
|
||||
<td>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('admin.user_detail', user_id=user.id) }}"
|
||||
class="btn btn-sm btn-outline-info" title="View Details">
|
||||
<i class="fas fa-eye"></i>
|
||||
</a>
|
||||
{% if not user.is_admin or current_user.id != user.id %}
|
||||
<button class="btn btn-sm btn-outline-warning" title="Toggle Status" disabled>
|
||||
<i class="fas fa-toggle-on"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if users.pages > 1 %}
|
||||
<nav aria-label="Users pagination" class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if users.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.users', page=users.prev_num) }}">Previous</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in users.iter_pages() %}
|
||||
{% if page_num %}
|
||||
{% if page_num != users.page %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.users', page=page_num) }}">{{ page_num }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ page_num }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">…</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if users.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('admin.users', page=users.next_num) }}">Next</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<div class="card">
|
||||
<div class="card-body text-center py-5">
|
||||
<i class="fas fa-users fa-3x text-muted mb-3"></i>
|
||||
<h5 class="text-muted">No users found</h5>
|
||||
<p class="text-muted">No users have registered yet.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -29,6 +29,9 @@
|
||||
<a href="{{ url_for('community.new_post') }}" class="bg-gradient-to-r from-green-500 to-emerald-500 text-white px-4 py-2 rounded-lg hover:from-green-400 hover:to-emerald-400 transition transform hover:scale-105">
|
||||
<i class="fas fa-plus mr-2"></i>Share Adventure
|
||||
</a>
|
||||
<a href="{{ url_for('community.profile') }}" class="text-white hover:text-blue-200 transition">
|
||||
<i class="fas fa-user mr-1"></i>My Profile
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="text-white hover:text-yellow-200 transition">
|
||||
<i class="fas fa-cog mr-1"></i>Admin
|
||||
@@ -61,6 +64,9 @@
|
||||
<a href="{{ url_for('community.new_post') }}" class="text-white block px-3 py-2 hover:bg-green-600 rounded">
|
||||
<i class="fas fa-plus mr-2"></i>New Post
|
||||
</a>
|
||||
<a href="{{ url_for('community.profile') }}" class="text-white block px-3 py-2 hover:bg-blue-600 rounded">
|
||||
<i class="fas fa-user mr-1"></i>My Profile
|
||||
</a>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('admin.dashboard') }}" class="text-white block px-3 py-2 hover:bg-yellow-600 rounded">
|
||||
<i class="fas fa-cog mr-1"></i>Admin
|
||||
|
||||
423
app/templates/community/edit_post.html
Normal file
423
app/templates/community/edit_post.html
Normal file
@@ -0,0 +1,423 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}Edit Adventure - {{ post.title }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<!-- Form Section -->
|
||||
<div class="col-lg-8">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h4 class="mb-0">
|
||||
<i class="fas fa-edit"></i> Edit Your Adventure
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form id="editPostForm" method="POST" enctype="multipart/form-data" action="{{ url_for('community.edit_post', id=post.id) }}">
|
||||
<!-- Title -->
|
||||
<div class="mb-4">
|
||||
<label for="title" class="form-label fw-bold">
|
||||
<i class="fas fa-heading text-primary"></i> Adventure Title *
|
||||
</label>
|
||||
<input type="text" class="form-control form-control-lg" id="title" name="title"
|
||||
value="{{ post.title }}" required maxlength="100"
|
||||
placeholder="Enter your adventure title">
|
||||
<div class="form-text">Make it catchy and descriptive!</div>
|
||||
</div>
|
||||
|
||||
<!-- Subtitle -->
|
||||
<div class="mb-4">
|
||||
<label for="subtitle" class="form-label fw-bold">
|
||||
<i class="fas fa-text-height text-info"></i> Subtitle
|
||||
</label>
|
||||
<input type="text" class="form-control" id="subtitle" name="subtitle"
|
||||
value="{{ post.subtitle or '' }}" maxlength="200"
|
||||
placeholder="A brief description of your adventure">
|
||||
<div class="form-text">Optional - appears under the main title</div>
|
||||
</div>
|
||||
|
||||
<!-- Difficulty Level -->
|
||||
<div class="mb-4">
|
||||
<label for="difficulty" class="form-label fw-bold">
|
||||
<i class="fas fa-mountain text-warning"></i> Difficulty Level *
|
||||
</label>
|
||||
<select class="form-select" id="difficulty" name="difficulty" required>
|
||||
<option value="">Select difficulty...</option>
|
||||
<option value="1" {% if post.difficulty == 1 %}selected{% endif %}>⭐ Easy - Beginner friendly</option>
|
||||
<option value="2" {% if post.difficulty == 2 %}selected{% endif %}>⭐⭐ Moderate - Some experience needed</option>
|
||||
<option value="3" {% if post.difficulty == 3 %}selected{% endif %}>⭐⭐⭐ Challenging - Good skills required</option>
|
||||
<option value="4" {% if post.difficulty == 4 %}selected{% endif %}>⭐⭐⭐⭐ Hard - Advanced riders only</option>
|
||||
<option value="5" {% if post.difficulty == 5 %}selected{% endif %}>⭐⭐⭐⭐⭐ Expert - Extreme difficulty</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Current Cover Image -->
|
||||
{% if post.images %}
|
||||
{% set cover_image = post.images | selectattr('is_cover', 'equalto', True) | first %}
|
||||
{% if cover_image %}
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-bold">
|
||||
<i class="fas fa-image text-success"></i> Current Cover Photo
|
||||
</label>
|
||||
<div class="current-cover-preview">
|
||||
<img src="{{ cover_image.get_thumbnail_url() }}" alt="Current cover"
|
||||
class="img-thumbnail" style="max-width: 200px;">
|
||||
<small class="text-muted d-block mt-1">{{ cover_image.original_name }}</small>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
|
||||
<!-- Cover Photo Upload -->
|
||||
<div class="mb-4">
|
||||
<label for="cover_picture" class="form-label fw-bold">
|
||||
<i class="fas fa-camera text-success"></i>
|
||||
{% if post.images and post.images | selectattr('is_cover', 'equalto', True) | first %}
|
||||
Replace Cover Photo
|
||||
{% else %}
|
||||
Cover Photo
|
||||
{% endif %}
|
||||
</label>
|
||||
<input type="file" class="form-control" id="cover_picture" name="cover_picture"
|
||||
accept="image/*" onchange="previewCoverImage(this)">
|
||||
<div class="form-text">
|
||||
Optional - Upload a new cover photo to replace the current one
|
||||
</div>
|
||||
<div id="cover_preview" class="mt-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Adventure Story/Content -->
|
||||
<div class="mb-4">
|
||||
<label for="content" class="form-label fw-bold">
|
||||
<i class="fas fa-book text-primary"></i> Your Adventure Story *
|
||||
</label>
|
||||
<textarea class="form-control" id="content" name="content" rows="8" required
|
||||
placeholder="Tell us about your adventure... Where did you go? What did you see? Any challenges or highlights?">{{ post.content }}</textarea>
|
||||
<div class="form-text">
|
||||
<i class="fas fa-info-circle"></i>
|
||||
You can use **bold text** and *italic text* in your story!
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Current GPX File -->
|
||||
{% if post.gpx_files %}
|
||||
<div class="mb-4">
|
||||
<label class="form-label fw-bold">
|
||||
<i class="fas fa-route text-info"></i> Current GPS Track
|
||||
</label>
|
||||
{% for gpx_file in post.gpx_files %}
|
||||
<div class="current-gpx-file border rounded p-3 bg-light">
|
||||
<div class="d-flex align-items-center">
|
||||
<i class="fas fa-file-alt text-info me-2"></i>
|
||||
<div>
|
||||
<strong>{{ gpx_file.original_name }}</strong>
|
||||
<small class="text-muted d-block">
|
||||
Size: {{ "%.1f"|format(gpx_file.size / 1024) }} KB
|
||||
• Uploaded: {{ gpx_file.created_at.strftime('%Y-%m-%d') }}
|
||||
</small>
|
||||
</div>
|
||||
<a href="{{ url_for('community.serve_gpx', post_folder=post.media_folder, filename=gpx_file.filename) }}"
|
||||
class="btn btn-sm btn-outline-primary ms-auto">
|
||||
<i class="fas fa-download"></i> Download
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- GPX File Upload -->
|
||||
<div class="mb-4">
|
||||
<label for="gpx_file" class="form-label fw-bold">
|
||||
<i class="fas fa-route text-info"></i>
|
||||
{% if post.gpx_files %}
|
||||
Replace GPS Track File
|
||||
{% else %}
|
||||
GPS Track File (GPX)
|
||||
{% endif %}
|
||||
</label>
|
||||
<input type="file" class="form-control" id="gpx_file" name="gpx_file"
|
||||
accept=".gpx" onchange="validateGpxFile(this)">
|
||||
<div class="form-text">
|
||||
Optional - Upload a new GPX file to replace the current route
|
||||
</div>
|
||||
<div id="gpx_info" class="mt-2"></div>
|
||||
</div>
|
||||
|
||||
<!-- Submit Buttons -->
|
||||
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
|
||||
<a href="{{ url_for('community.profile') }}" class="btn btn-secondary me-md-2">
|
||||
<i class="fas fa-arrow-left"></i> Cancel
|
||||
</a>
|
||||
<button type="button" class="btn btn-info me-md-2" onclick="previewPost()">
|
||||
<i class="fas fa-eye"></i> Preview Changes
|
||||
</button>
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="fas fa-paper-plane"></i> Update & Resubmit for Review
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Info Panel -->
|
||||
<div class="col-lg-4">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-info-circle"></i> Editing Guidelines
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<h6><i class="fas fa-edit text-primary"></i> What happens after editing?</h6>
|
||||
<p class="small">Your updated post will be resubmitted for admin review before being published again.</p>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<h6><i class="fas fa-image text-success"></i> Photo Guidelines</h6>
|
||||
<ul class="small mb-0">
|
||||
<li>Use high-quality images (JPEG, PNG)</li>
|
||||
<li>Landscape orientation works best for cover photos</li>
|
||||
<li>Maximum file size: 10MB</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<h6><i class="fas fa-route text-info"></i> GPX File Tips</h6>
|
||||
<ul class="small mb-0">
|
||||
<li>Export from your GPS device or app</li>
|
||||
<li>Should contain track points</li>
|
||||
<li>Will be displayed on the community map</li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<h6><i class="fas fa-star text-warning"></i> Difficulty Levels</h6>
|
||||
<div class="small">
|
||||
<div><strong>Easy:</strong> Paved roads, good weather</div>
|
||||
<div><strong>Moderate:</strong> Some gravel, hills</div>
|
||||
<div><strong>Challenging:</strong> Off-road, technical</div>
|
||||
<div><strong>Hard:</strong> Extreme conditions</div>
|
||||
<div><strong>Expert:</strong> Dangerous, experts only</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
<strong>Note:</strong> Updating your post will reset its status to "pending review."
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Preview Modal -->
|
||||
<div class="modal fade" id="previewModal" tabindex="-1" aria-labelledby="previewModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="previewModalLabel">
|
||||
<i class="fas fa-eye"></i> Post Preview
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="previewContent">
|
||||
<!-- Preview content will be loaded here -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Close Preview</button>
|
||||
<button type="button" class="btn btn-success" onclick="submitForm()">
|
||||
<i class="fas fa-paper-plane"></i> Looks Good - Update Post
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- JavaScript -->
|
||||
<script>
|
||||
function previewCoverImage(input) {
|
||||
const preview = document.getElementById('cover_preview');
|
||||
preview.innerHTML = '';
|
||||
|
||||
if (input.files && input.files[0]) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
preview.innerHTML = `
|
||||
<div class="mt-2">
|
||||
<img src="${e.target.result}" alt="Cover preview" class="img-thumbnail" style="max-width: 200px;">
|
||||
<small class="text-success d-block mt-1">✓ New cover photo ready</small>
|
||||
</div>
|
||||
`;
|
||||
};
|
||||
reader.readAsDataURL(input.files[0]);
|
||||
}
|
||||
}
|
||||
|
||||
function validateGpxFile(input) {
|
||||
const info = document.getElementById('gpx_info');
|
||||
info.innerHTML = '';
|
||||
|
||||
if (input.files && input.files[0]) {
|
||||
const file = input.files[0];
|
||||
if (file.name.toLowerCase().endsWith('.gpx')) {
|
||||
info.innerHTML = `
|
||||
<div class="alert alert-success">
|
||||
<i class="fas fa-check-circle"></i>
|
||||
GPX file selected: ${file.name} (${(file.size / 1024).toFixed(1)} KB)
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
info.innerHTML = `
|
||||
<div class="alert alert-danger">
|
||||
<i class="fas fa-exclamation-triangle"></i>
|
||||
Please select a valid GPX file
|
||||
</div>
|
||||
`;
|
||||
input.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function previewPost() {
|
||||
// Get form data
|
||||
const title = document.getElementById('title').value;
|
||||
const subtitle = document.getElementById('subtitle').value;
|
||||
const content = document.getElementById('content').value;
|
||||
const difficulty = document.getElementById('difficulty').value;
|
||||
|
||||
// Get difficulty stars
|
||||
const difficultyStars = '⭐'.repeat(difficulty);
|
||||
const difficultyLabels = {
|
||||
'1': 'Easy',
|
||||
'2': 'Moderate',
|
||||
'3': 'Challenging',
|
||||
'4': 'Hard',
|
||||
'5': 'Expert'
|
||||
};
|
||||
|
||||
// Format content (simple markdown-like formatting)
|
||||
const formattedContent = content
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
.replace(/\n/g, '<br>');
|
||||
|
||||
// Generate preview HTML
|
||||
const previewHTML = `
|
||||
<div class="post-preview">
|
||||
<!-- Hero Section -->
|
||||
<div class="hero-section bg-primary text-white p-4 rounded mb-4">
|
||||
<div class="container">
|
||||
<h1 class="display-4 mb-2">${title || 'Your Adventure Title'}</h1>
|
||||
${subtitle ? `<p class="lead mb-3">${subtitle}</p>` : ''}
|
||||
<div class="d-flex align-items-center">
|
||||
<span class="badge bg-warning text-dark me-3">
|
||||
${difficultyStars} ${difficultyLabels[difficulty] || 'Select difficulty'}
|
||||
</span>
|
||||
<small>By {{ current_user.nickname }} • Updated today</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Content Section -->
|
||||
<div class="container">
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="adventure-content">
|
||||
<h3>Adventure Story</h3>
|
||||
<div class="content-text">
|
||||
${formattedContent || '<em>No content provided yet...</em>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="adventure-info">
|
||||
<h5>Adventure Details</h5>
|
||||
<ul class="list-unstyled">
|
||||
<li><strong>Difficulty:</strong> ${difficultyStars} ${difficultyLabels[difficulty] || 'Not set'}</li>
|
||||
<li><strong>Status:</strong> <span class="badge bg-warning">Pending Review</span></li>
|
||||
<li><strong>Last Updated:</strong> Today</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Show preview in modal
|
||||
document.getElementById('previewContent').innerHTML = previewHTML;
|
||||
const modal = new bootstrap.Modal(document.getElementById('previewModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
function submitForm() {
|
||||
document.getElementById('editPostForm').submit();
|
||||
}
|
||||
|
||||
// Form submission with AJAX
|
||||
document.getElementById('editPostForm').addEventListener('submit', function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const formData = new FormData(this);
|
||||
const submitButton = this.querySelector('button[type="submit"]');
|
||||
const originalText = submitButton.innerHTML;
|
||||
|
||||
// Show loading state
|
||||
submitButton.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Updating...';
|
||||
submitButton.disabled = true;
|
||||
|
||||
fetch(this.action, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Show success message
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-success alert-dismissible fade show';
|
||||
alert.innerHTML = `
|
||||
<i class="fas fa-check-circle"></i> ${data.message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
this.insertBefore(alert, this.firstChild);
|
||||
|
||||
// Redirect after delay
|
||||
setTimeout(() => {
|
||||
window.location.href = data.redirect_url;
|
||||
}, 2000);
|
||||
} else {
|
||||
// Show error message
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-danger alert-dismissible fade show';
|
||||
alert.innerHTML = `
|
||||
<i class="fas fa-exclamation-triangle"></i> ${data.error}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
this.insertBefore(alert, this.firstChild);
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-danger alert-dismissible fade show';
|
||||
alert.innerHTML = `
|
||||
<i class="fas fa-exclamation-triangle"></i> An error occurred while updating your post.
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
this.insertBefore(alert, this.firstChild);
|
||||
})
|
||||
.finally(() => {
|
||||
// Restore button state
|
||||
submitButton.innerHTML = originalText;
|
||||
submitButton.disabled = false;
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -177,13 +177,13 @@
|
||||
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"></path>
|
||||
</svg>
|
||||
{{ post.comments|length }}
|
||||
{{ post.comments.count() }}
|
||||
</span>
|
||||
<span class="flex items-center">
|
||||
<svg class="w-3 h-3 mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z"></path>
|
||||
</svg>
|
||||
{{ post.likes|length }}
|
||||
{{ post.likes.count() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -130,7 +130,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Main Form -->
|
||||
<form id="postForm" enctype="multipart/form-data" class="space-y-6">
|
||||
<form id="adventure-form" method="POST" action="{{ url_for('community.new_post') }}" enctype="multipart/form-data" class="space-y-6">
|
||||
<!-- Basic Information Section -->
|
||||
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-6 border border-white/20">
|
||||
<h2 class="text-2xl font-bold text-white mb-6">📝 Basic Information</h2>
|
||||
@@ -228,7 +228,11 @@
|
||||
</div>
|
||||
|
||||
<!-- Submit -->
|
||||
<div class="text-center">
|
||||
<div class="text-center space-x-4">
|
||||
<button type="button" onclick="previewPost()" class="bg-gradient-to-r from-blue-500 to-indigo-600 text-white px-8 py-4 rounded-lg font-bold text-lg hover:from-blue-600 hover:to-indigo-700 transform hover:scale-105 transition-all duration-200 shadow-lg">
|
||||
<i class="fas fa-eye mr-3"></i>
|
||||
Preview Adventure
|
||||
</button>
|
||||
<button type="submit" class="bg-gradient-to-r from-orange-500 to-red-600 text-white px-12 py-4 rounded-lg font-bold text-lg hover:from-orange-600 hover:to-red-700 transform hover:scale-105 transition-all duration-200 shadow-lg">
|
||||
<i class="fas fa-paper-plane mr-3"></i>
|
||||
Share Adventure
|
||||
@@ -242,6 +246,38 @@
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
let sectionCounter = 0;
|
||||
|
||||
// Populate form from URL parameters
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
if (urlParams.get('title')) {
|
||||
document.getElementById('title').value = urlParams.get('title');
|
||||
}
|
||||
if (urlParams.get('subtitle')) {
|
||||
document.getElementById('subtitle').value = urlParams.get('subtitle');
|
||||
}
|
||||
if (urlParams.get('difficulty')) {
|
||||
document.getElementById('difficulty').value = urlParams.get('difficulty');
|
||||
}
|
||||
if (urlParams.get('cover_picture')) {
|
||||
// Note: This would be the filename, but we can't pre-populate file inputs for security reasons
|
||||
// We'll show a message instead
|
||||
const coverUploadArea = document.querySelector('.cover-upload-area');
|
||||
const coverContent = document.querySelector('.cover-upload-content');
|
||||
coverContent.innerHTML = `
|
||||
<div class="text-4xl mb-2">📸</div>
|
||||
<p class="text-yellow-300 mb-2">Cover image suggested: ${urlParams.get('cover_picture')}</p>
|
||||
<p class="text-white/80 mb-2">Click to upload this or another cover image</p>
|
||||
<p class="text-sm text-white/60">Recommended: 1920x1080 pixels</p>
|
||||
`;
|
||||
}
|
||||
if (urlParams.get('gpx_file')) {
|
||||
// Similar note for GPX file
|
||||
const gpxLabel = document.querySelector('label[for="gpx-file"]');
|
||||
gpxLabel.innerHTML = `
|
||||
<i class="fas fa-upload mr-2"></i>
|
||||
Choose GPX File (suggested: ${urlParams.get('gpx_file')})
|
||||
`;
|
||||
}
|
||||
|
||||
// Cover picture upload
|
||||
const coverUploadArea = document.querySelector('.cover-upload-area');
|
||||
const coverInput = document.getElementById('cover_picture');
|
||||
@@ -531,12 +567,8 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if at least one section is saved
|
||||
// Check if at least one section is saved or allow empty content for now
|
||||
const savedSections = document.querySelectorAll('.content-section.saved');
|
||||
if (savedSections.length === 0) {
|
||||
alert('Please save at least one content section.');
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect all form data
|
||||
const formData = new FormData();
|
||||
@@ -546,50 +578,245 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Collect content from saved sections
|
||||
let fullContent = '';
|
||||
savedSections.forEach((section, index) => {
|
||||
const highlights = Array.from(section.querySelectorAll('.saved-highlights .highlight-display'))
|
||||
.map(span => span.textContent);
|
||||
const text = section.querySelector('.saved-text').textContent;
|
||||
|
||||
if (highlights.length > 0) {
|
||||
fullContent += highlights.map(h => `**${h}**`).join(' ') + '\n\n';
|
||||
}
|
||||
if (text) {
|
||||
fullContent += text + '\n\n';
|
||||
}
|
||||
});
|
||||
if (savedSections.length > 0) {
|
||||
savedSections.forEach((section, index) => {
|
||||
const highlights = Array.from(section.querySelectorAll('.saved-highlights .highlight-display'))
|
||||
.map(span => span.textContent);
|
||||
const text = section.querySelector('.saved-text').textContent;
|
||||
|
||||
if (highlights.length > 0) {
|
||||
fullContent += highlights.map(h => `**${h}**`).join(' ') + '\n\n';
|
||||
}
|
||||
if (text) {
|
||||
fullContent += text + '\n\n';
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// If no sections saved, use a default message
|
||||
fullContent = 'Adventure details will be added soon.';
|
||||
}
|
||||
|
||||
formData.append('content', fullContent);
|
||||
|
||||
// Add cover picture if selected
|
||||
const coverFile = document.getElementById('cover_picture').files[0];
|
||||
if (coverFile) {
|
||||
formData.append('cover_picture', coverFile);
|
||||
}
|
||||
|
||||
// Add GPX file if selected
|
||||
const gpxFile = document.getElementById('gpx-file').files[0];
|
||||
if (gpxFile) {
|
||||
formData.append('gpx_file', gpxFile);
|
||||
}
|
||||
|
||||
// Add images (simplified - in a real implementation, you'd handle this properly)
|
||||
const allImages = document.querySelectorAll('.saved-images img');
|
||||
// Note: This is a simplified version. In a real implementation,
|
||||
// you'd need to properly handle the image files
|
||||
// Show loading state
|
||||
const submitBtn = document.querySelector('button[type="submit"]');
|
||||
const originalText = submitBtn.innerHTML;
|
||||
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin mr-3"></i>Creating Adventure...';
|
||||
submitBtn.disabled = true;
|
||||
|
||||
// Submit form
|
||||
fetch('{{ url_for("community.new_post") }}', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
window.location.href = data.redirect_url;
|
||||
.then(response => {
|
||||
if (response.redirected) {
|
||||
// Follow redirect
|
||||
window.location.href = response.url;
|
||||
} else if (response.ok) {
|
||||
// Check if it's JSON response
|
||||
return response.text().then(text => {
|
||||
try {
|
||||
const data = JSON.parse(text);
|
||||
if (data.success) {
|
||||
window.location.href = data.redirect_url || '/community/';
|
||||
} else {
|
||||
throw new Error(data.error || 'Unknown error');
|
||||
}
|
||||
} catch (e) {
|
||||
// Not JSON, probably HTML redirect response
|
||||
window.location.href = '/community/';
|
||||
}
|
||||
});
|
||||
} else {
|
||||
alert('Error creating post: ' + data.error);
|
||||
throw new Error('Server error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while creating your post.');
|
||||
alert('An error occurred while creating your post. Please try again.');
|
||||
|
||||
// Restore button
|
||||
submitBtn.innerHTML = originalText;
|
||||
submitBtn.disabled = false;
|
||||
});
|
||||
});
|
||||
|
||||
// Preview function
|
||||
function previewPost() {
|
||||
// Get form data
|
||||
const title = document.getElementById('title').value;
|
||||
const subtitle = document.getElementById('subtitle').value;
|
||||
const content = document.getElementById('content').value;
|
||||
const difficulty = document.getElementById('difficulty').value;
|
||||
|
||||
// Get difficulty stars and labels
|
||||
const difficultyStars = '⭐'.repeat(difficulty);
|
||||
const difficultyLabels = {
|
||||
'1': 'Easy - Beginner friendly',
|
||||
'2': 'Moderate - Some experience needed',
|
||||
'3': 'Challenging - Good skills required',
|
||||
'4': 'Hard - Advanced riders only',
|
||||
'5': 'Expert - Extreme difficulty'
|
||||
};
|
||||
|
||||
// Format content (simple markdown-like formatting)
|
||||
const formattedContent = content
|
||||
.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>')
|
||||
.replace(/\*(.*?)\*/g, '<em>$1</em>')
|
||||
.replace(/\n/g, '<br>');
|
||||
|
||||
// Get cover image preview if available
|
||||
const coverInput = document.getElementById('cover_picture');
|
||||
let coverImageHtml = '';
|
||||
if (coverInput.files && coverInput.files[0]) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = function(e) {
|
||||
showPreviewModal(title, subtitle, formattedContent, difficulty, difficultyStars, difficultyLabels, e.target.result);
|
||||
};
|
||||
reader.readAsDataURL(coverInput.files[0]);
|
||||
} else {
|
||||
showPreviewModal(title, subtitle, formattedContent, difficulty, difficultyStars, difficultyLabels, null);
|
||||
}
|
||||
}
|
||||
|
||||
function showPreviewModal(title, subtitle, formattedContent, difficulty, difficultyStars, difficultyLabels, coverImageSrc) {
|
||||
// Create cover image section
|
||||
let coverImageHtml = '';
|
||||
if (coverImageSrc) {
|
||||
coverImageHtml = `
|
||||
<div class="position-relative mb-4">
|
||||
<img src="${coverImageSrc}" alt="Cover preview" class="w-100 rounded" style="max-height: 300px; object-fit: cover;">
|
||||
<div class="position-absolute top-0 start-0 w-100 h-100 d-flex align-items-end" style="background: linear-gradient(transparent, rgba(0,0,0,0.6));">
|
||||
<div class="p-4 text-white">
|
||||
<h1 class="display-4 mb-2">${title || 'Your Adventure Title'}</h1>
|
||||
${subtitle ? `<p class="lead mb-0">${subtitle}</p>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
} else {
|
||||
coverImageHtml = `
|
||||
<div class="bg-primary text-white p-4 rounded mb-4">
|
||||
<h1 class="display-4 mb-2">${title || 'Your Adventure Title'}</h1>
|
||||
${subtitle ? `<p class="lead mb-0">${subtitle}</p>` : ''}
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// Generate preview HTML
|
||||
const previewHTML = `
|
||||
<div class="post-preview">
|
||||
${coverImageHtml}
|
||||
|
||||
<div class="row">
|
||||
<div class="col-lg-8">
|
||||
<div class="adventure-content">
|
||||
<h3><i class="fas fa-book text-primary"></i> Adventure Story</h3>
|
||||
<div class="content-text mb-4">
|
||||
${formattedContent || '<em class="text-muted">No content provided yet...</em>'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-lg-4">
|
||||
<div class="card">
|
||||
<div class="card-header bg-light">
|
||||
<h5 class="mb-0"><i class="fas fa-info-circle"></i> Adventure Details</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="mb-3">
|
||||
<strong>Difficulty:</strong><br>
|
||||
<span class="badge bg-warning text-dark fs-6">
|
||||
${difficultyStars} ${difficultyLabels[difficulty] || 'Not set'}
|
||||
</span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Status:</strong><br>
|
||||
<span class="badge bg-warning">Pending Review</span>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<strong>Author:</strong><br>
|
||||
{{ current_user.nickname }}
|
||||
</div>
|
||||
<div>
|
||||
<strong>Created:</strong><br>
|
||||
Today
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-3">
|
||||
<div class="card-header bg-info text-white">
|
||||
<h6 class="mb-0"><i class="fas fa-map-marked-alt"></i> Route Information</h6>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
${document.getElementById('gpx_file').files.length > 0 ?
|
||||
'<div class="alert alert-success small"><i class="fas fa-check"></i> GPS track will be displayed on map</div>' :
|
||||
'<div class="alert alert-info small"><i class="fas fa-info"></i> No GPS track uploaded</div>'
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Show preview in modal
|
||||
showModal('Adventure Preview', previewHTML);
|
||||
}
|
||||
|
||||
function showModal(title, content) {
|
||||
// Create modal if it doesn't exist
|
||||
let modal = document.getElementById('previewModal');
|
||||
if (!modal) {
|
||||
modal = document.createElement('div');
|
||||
modal.innerHTML = `
|
||||
<div class="modal fade" id="previewModal" tabindex="-1" aria-labelledby="previewModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-xl">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title" id="previewModalLabel">
|
||||
<i class="fas fa-eye"></i> ${title}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div id="previewContent">${content}</div>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times"></i> Close Preview
|
||||
</button>
|
||||
<button type="button" class="btn btn-success" onclick="document.getElementById('createPostForm').submit()">
|
||||
<i class="fas fa-paper-plane"></i> Looks Good - Share Adventure
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
document.body.appendChild(modal);
|
||||
} else {
|
||||
document.getElementById('previewContent').innerHTML = content;
|
||||
document.getElementById('previewModalLabel').innerHTML = `<i class="fas fa-eye"></i> ${title}`;
|
||||
}
|
||||
|
||||
// Show the modal
|
||||
const bootstrapModal = new bootstrap.Modal(document.getElementById('previewModal'));
|
||||
bootstrapModal.show();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
521
app/templates/community/post_detail.html
Normal file
521
app/templates/community/post_detail.html
Normal file
@@ -0,0 +1,521 @@
|
||||
{% 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 %}
|
||||
941
app/templates/community/post_detail_fixed.html
Normal file
941
app/templates/community/post_detail_fixed.html
Normal file
@@ -0,0 +1,941 @@
|
||||
{% 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 %}
|
||||
521
app/templates/community/post_detail_new.html
Normal file
521
app/templates/community/post_detail_new.html
Normal file
@@ -0,0 +1,521 @@
|
||||
{% 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 %}
|
||||
297
app/templates/community/profile.html
Normal file
297
app/templates/community/profile.html
Normal file
@@ -0,0 +1,297 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}My Profile - {{ current_user.nickname }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="min-h-screen bg-gradient-to-br from-blue-900 via-purple-900 to-teal-900 pt-16">
|
||||
<!-- Profile Header -->
|
||||
<div class="relative overflow-hidden py-16">
|
||||
<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">
|
||||
<div class="bg-white/10 backdrop-blur-sm rounded-2xl p-8 border border-white/20">
|
||||
<div class="flex flex-col md:flex-row md:items-center md:justify-between gap-6">
|
||||
<div class="flex items-center space-x-6">
|
||||
<div class="w-20 h-20 bg-gradient-to-r from-orange-500 to-red-600 rounded-full flex items-center justify-center text-white font-bold text-3xl">
|
||||
{{ current_user.nickname[0].upper() }}
|
||||
</div>
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-white mb-2">{{ current_user.nickname }}</h1>
|
||||
<p class="text-blue-200 mb-1">
|
||||
<i class="fas fa-envelope mr-2"></i>{{ current_user.email }}
|
||||
</p>
|
||||
<p class="text-blue-200">
|
||||
<i class="fas fa-calendar mr-2"></i>Riding since {{ current_user.created_at.strftime('%B %Y') }}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<a href="{{ url_for('community.new_post') }}"
|
||||
class="inline-flex items-center px-6 py-3 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-plus mr-2"></i>Share New Adventure
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 pb-12 -mt-8">
|
||||
<!-- Adventure Statistics -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-8">
|
||||
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
<div class="bg-gradient-to-r from-green-600 to-emerald-600 p-6">
|
||||
<div class="flex items-center text-white">
|
||||
<i class="fas fa-check-circle text-3xl mr-4"></i>
|
||||
<div>
|
||||
<div class="text-3xl font-bold">{{ published_count }}</div>
|
||||
<div class="text-green-100">Published Adventures</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<p class="text-gray-600 text-sm">Visible to the community</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-2xl shadow-xl overflow-hidden">
|
||||
<div class="bg-gradient-to-r from-yellow-600 to-orange-600 p-6">
|
||||
<div class="flex items-center text-white">
|
||||
<i class="fas fa-clock text-3xl mr-4"></i>
|
||||
<div>
|
||||
<div class="text-3xl font-bold">{{ pending_count }}</div>
|
||||
<div class="text-yellow-100">Awaiting Review</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<p class="text-gray-600 text-sm">Pending admin approval</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Adventures Collection Header -->
|
||||
<div class="bg-white rounded-2xl shadow-xl p-6 mb-8">
|
||||
<div class="flex items-center">
|
||||
<i class="fas fa-mountain text-3xl text-blue-600 mr-4"></i>
|
||||
<div>
|
||||
<h2 class="text-2xl font-bold text-gray-900">My Adventure Collection</h2>
|
||||
<p class="text-gray-600">Manage and share your motorcycle adventures</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Adventures Grid -->
|
||||
{% if posts.items %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{% for post in posts.items %}
|
||||
<div class="bg-white rounded-2xl shadow-xl overflow-hidden transition-all duration-300 hover:shadow-2xl hover:scale-105">
|
||||
<!-- Adventure Image -->
|
||||
{% set cover_image = post.images | selectattr('is_cover', 'equalto', True) | first %}
|
||||
{% if cover_image %}
|
||||
<div class="relative">
|
||||
<img src="{{ cover_image.get_thumbnail_url() }}"
|
||||
alt="{{ post.title }}"
|
||||
class="w-full h-48 object-cover">
|
||||
<div class="absolute inset-0 bg-gradient-to-t from-black/50 to-transparent"></div>
|
||||
<div class="absolute top-3 right-3">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-semibold
|
||||
{{ 'bg-green-500 text-white' if post.published else 'bg-yellow-500 text-black' }}">
|
||||
{% if post.published %}
|
||||
<i class="fas fa-check-circle mr-1"></i>Live
|
||||
{% else %}
|
||||
<i class="fas fa-clock mr-1"></i>Review
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="h-48 bg-gradient-to-br from-blue-600 to-purple-600 flex items-center justify-center relative">
|
||||
<i class="fas fa-mountain text-white text-6xl opacity-50"></i>
|
||||
<div class="absolute top-3 right-3">
|
||||
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-semibold
|
||||
{{ 'bg-green-500 text-white' if post.published else 'bg-yellow-500 text-black' }}">
|
||||
{% if post.published %}
|
||||
<i class="fas fa-check-circle mr-1"></i>Live
|
||||
{% else %}
|
||||
<i class="fas fa-clock mr-1"></i>Review
|
||||
{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="p-6">
|
||||
<!-- Title and Subtitle -->
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-2">{{ post.title }}</h3>
|
||||
{% if post.subtitle %}
|
||||
<p class="text-gray-600 text-sm mb-3">{{ post.subtitle }}</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Difficulty Badge -->
|
||||
<div class="mb-4">
|
||||
<span class="inline-flex items-center px-3 py-1 rounded-full bg-gradient-to-r from-yellow-500 to-orange-500 text-white text-sm font-semibold">
|
||||
{% for i in range(post.difficulty) %}
|
||||
<i class="fas fa-star mr-1"></i>
|
||||
{% endfor %}
|
||||
{{ post.get_difficulty_label() }}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Content Preview -->
|
||||
<p class="text-gray-600 text-sm mb-4 h-12 overflow-hidden">
|
||||
{{ (post.content[:100] + '...') if post.content|length > 100 else post.content }}
|
||||
</p>
|
||||
|
||||
<!-- Adventure Metadata -->
|
||||
<div class="bg-gray-50 rounded-lg p-4 mb-4">
|
||||
<div class="grid grid-cols-3 gap-4 text-center">
|
||||
<div>
|
||||
<i class="fas fa-calendar text-blue-500 mb-1"></i>
|
||||
<div class="text-sm font-semibold">{{ post.created_at.strftime('%m/%d') }}</div>
|
||||
<div class="text-xs text-gray-500">Created</div>
|
||||
</div>
|
||||
<div>
|
||||
<i class="fas fa-images text-green-500 mb-1"></i>
|
||||
<div class="text-sm font-semibold">{{ post.images.count() }}</div>
|
||||
<div class="text-xs text-gray-500">Photos</div>
|
||||
</div>
|
||||
<div>
|
||||
<i class="fas fa-route text-orange-500 mb-1"></i>
|
||||
<div class="text-sm font-semibold">{{ post.gpx_files.count() }}</div>
|
||||
<div class="text-xs text-gray-500">Routes</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="space-y-2">
|
||||
{% if post.published %}
|
||||
<a href="{{ url_for('community.post_detail', id=post.id) }}"
|
||||
class="block w-full text-center px-4 py-2 bg-blue-600 text-white font-semibold rounded-lg hover:bg-blue-700 transition-all duration-200">
|
||||
<i class="fas fa-eye mr-2"></i>View Live
|
||||
</a>
|
||||
{% endif %}
|
||||
<div class="grid grid-cols-2 gap-2">
|
||||
<a href="{{ url_for('community.edit_post', id=post.id) }}"
|
||||
class="text-center px-3 py-2 bg-yellow-500 text-white font-semibold rounded-lg hover:bg-yellow-600 transition-all duration-200">
|
||||
<i class="fas fa-edit mr-1"></i>Edit
|
||||
</a>
|
||||
<button type="button"
|
||||
class="px-3 py-2 bg-red-500 text-white font-semibold rounded-lg hover:bg-red-600 transition-all duration-200"
|
||||
data-post-id="{{ post.id }}"
|
||||
data-post-title="{{ post.title }}"
|
||||
onclick="confirmDelete(this)">
|
||||
<i class="fas fa-trash mr-1"></i>Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<!-- No Adventures State -->
|
||||
<div class="bg-white rounded-2xl shadow-xl p-12 text-center">
|
||||
<i class="fas fa-mountain text-6xl text-gray-400 mb-6"></i>
|
||||
<h3 class="text-2xl font-bold text-gray-900 mb-4">Your Adventure Journey Awaits!</h3>
|
||||
<p class="text-gray-600 mb-8 max-w-md mx-auto">
|
||||
You haven't shared any motorcycle adventures yet. Every great ride has a story worth telling!
|
||||
</p>
|
||||
<a href="{{ url_for('community.new_post') }}"
|
||||
class="inline-flex items-center px-8 py-4 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-plus mr-2"></i>Share Your First Adventure
|
||||
</a>
|
||||
<div class="mt-6">
|
||||
<p class="text-sm text-gray-500">
|
||||
<i class="fas fa-lightbulb mr-1"></i>
|
||||
Share photos, GPS tracks, and stories of your rides to inspire the community
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if posts.pages > 1 %}
|
||||
<div class="flex justify-center mt-8">
|
||||
<div class="bg-white rounded-lg shadow-lg px-6 py-4">
|
||||
<div class="flex items-center space-x-4">
|
||||
{% if posts.has_prev %}
|
||||
<a href="{{ url_for('community.profile', page=posts.prev_num) }}"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200">
|
||||
<i class="fas fa-chevron-left mr-1"></i>Previous
|
||||
</a>
|
||||
{% endif %}
|
||||
|
||||
<span class="text-gray-600">
|
||||
Page {{ posts.page }} of {{ posts.pages }}
|
||||
</span>
|
||||
|
||||
{% if posts.has_next %}
|
||||
<a href="{{ url_for('community.profile', page=posts.next_num) }}"
|
||||
class="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-all duration-200">
|
||||
Next<i class="fas fa-chevron-right ml-1"></i>
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div id="deleteModal" class="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50 hidden">
|
||||
<div class="bg-white rounded-2xl p-8 max-w-md mx-4">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-exclamation-triangle text-red-500 text-4xl mb-4"></i>
|
||||
<h3 class="text-xl font-bold text-gray-900 mb-4">Delete Adventure</h3>
|
||||
<p class="text-gray-600 mb-6">
|
||||
Are you sure you want to delete "<span id="deletePostTitle" class="font-semibold"></span>"?
|
||||
This action cannot be undone.
|
||||
</p>
|
||||
<div class="flex space-x-4">
|
||||
<button onclick="closeDeleteModal()"
|
||||
class="flex-1 px-4 py-2 bg-gray-300 text-gray-700 rounded-lg hover:bg-gray-400 transition-all duration-200">
|
||||
Cancel
|
||||
</button>
|
||||
<form id="deleteForm" method="POST" class="flex-1">
|
||||
<button type="submit"
|
||||
class="w-full px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-all duration-200">
|
||||
Delete
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function confirmDelete(button) {
|
||||
const postId = button.getAttribute('data-post-id');
|
||||
const postTitle = button.getAttribute('data-post-title');
|
||||
|
||||
document.getElementById('deletePostTitle').textContent = postTitle;
|
||||
document.getElementById('deleteForm').action = `/community/delete-post/${postId}`;
|
||||
document.getElementById('deleteModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeDeleteModal() {
|
||||
document.getElementById('deleteModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
// Close modal when clicking outside
|
||||
document.getElementById('deleteModal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeDeleteModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Close modal on escape key
|
||||
document.addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Escape') {
|
||||
closeDeleteModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
312
app/templates/community/profile_new.html
Normal file
312
app/templates/community/profile_new.html
Normal file
@@ -0,0 +1,312 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title %}My Profile - {{ current_user.nickname }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid mt-4">
|
||||
<!-- Header Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-body">
|
||||
<div class="row align-items-center">
|
||||
<div class="col-md-8">
|
||||
<h1 class="mb-3">
|
||||
<i class="fas fa-user-circle text-primary"></i>
|
||||
{{ current_user.nickname }}'s Profile
|
||||
</h1>
|
||||
<p class="text-muted mb-0">
|
||||
<i class="fas fa-envelope"></i> {{ current_user.email }}
|
||||
• <i class="fas fa-calendar"></i> Member since {{ current_user.created_at.strftime('%B %Y') }}
|
||||
</p>
|
||||
</div>
|
||||
<div class="col-md-4 text-md-end">
|
||||
<a href="{{ url_for('community.new_post') }}" class="btn btn-primary btn-lg">
|
||||
<i class="fas fa-plus"></i> Create New Adventure
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats Section -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-6">
|
||||
<div class="card text-center border-success">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-check-circle fa-3x text-success mb-3"></i>
|
||||
<h3 class="text-success">{{ published_count }}</h3>
|
||||
<p class="text-muted mb-0">Published Adventures</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="card text-center border-warning">
|
||||
<div class="card-body">
|
||||
<i class="fas fa-clock fa-3x text-warning mb-3"></i>
|
||||
<h3 class="text-warning">{{ pending_count }}</h3>
|
||||
<p class="text-muted mb-0">Pending Review</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Posts Section -->
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="card shadow-sm">
|
||||
<div class="card-header bg-dark text-white">
|
||||
<h4 class="mb-0">
|
||||
<i class="fas fa-list"></i> My Adventures
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if posts.items %}
|
||||
<div class="row">
|
||||
{% for post in posts.items %}
|
||||
<div class="col-lg-6 col-xl-4 mb-4">
|
||||
<div class="card h-100 {{ 'border-success' if post.published else 'border-warning' }}">
|
||||
<!-- Cover Image -->
|
||||
{% set cover_image = post.images | selectattr('is_cover', 'equalto', True) | first %}
|
||||
{% if cover_image %}
|
||||
<div class="position-relative">
|
||||
<img src="{{ cover_image.get_thumbnail_url() }}"
|
||||
alt="{{ post.title }}"
|
||||
class="card-img-top"
|
||||
style="height: 200px; object-fit: cover;">
|
||||
<div class="position-absolute top-0 end-0 m-2">
|
||||
<span class="badge {{ 'bg-success' if post.published else 'bg-warning text-dark' }}">
|
||||
{{ 'Published' if post.published else 'Pending Review' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="card-img-top bg-gradient-primary d-flex align-items-center justify-content-center text-white" style="height: 200px;">
|
||||
<div class="text-center">
|
||||
<i class="fas fa-mountain fa-3x mb-2"></i>
|
||||
<div class="position-absolute top-0 end-0 m-2">
|
||||
<span class="badge {{ 'bg-success' if post.published else 'bg-warning text-dark' }}">
|
||||
{{ 'Published' if post.published else 'Pending Review' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<div class="card-body d-flex flex-column">
|
||||
<!-- Title and Subtitle -->
|
||||
<h5 class="card-title">{{ post.title }}</h5>
|
||||
{% if post.subtitle %}
|
||||
<p class="card-text text-muted small">{{ post.subtitle }}</p>
|
||||
{% endif %}
|
||||
|
||||
<!-- Difficulty -->
|
||||
<div class="mb-2">
|
||||
<span class="badge bg-warning text-dark">
|
||||
{{ '⭐' * post.difficulty }}
|
||||
{% if post.difficulty == 1 %}Easy{% elif post.difficulty == 2 %}Moderate{% elif post.difficulty == 3 %}Challenging{% elif post.difficulty == 4 %}Hard{% else %}Expert{% endif %}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Content Preview -->
|
||||
<p class="card-text flex-grow-1">
|
||||
{{ (post.content[:100] + '...') if post.content|length > 100 else post.content }}
|
||||
</p>
|
||||
|
||||
<!-- Meta Information -->
|
||||
<div class="text-muted small mb-3">
|
||||
<div>
|
||||
<i class="fas fa-calendar"></i>
|
||||
Created: {{ post.created_at.strftime('%Y-%m-%d') }}
|
||||
</div>
|
||||
{% if post.updated_at and post.updated_at != post.created_at %}
|
||||
<div>
|
||||
<i class="fas fa-edit"></i>
|
||||
Updated: {{ post.updated_at.strftime('%Y-%m-%d') }}
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<i class="fas fa-images"></i>
|
||||
{{ post.images.count() }} images
|
||||
• <i class="fas fa-route"></i>
|
||||
{{ post.gpx_files.count() }} GPS tracks
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="d-grid gap-2">
|
||||
<div class="btn-group" role="group">
|
||||
{% if post.published %}
|
||||
<a href="{{ url_for('community.post_detail', id=post.id) }}"
|
||||
class="btn btn-outline-primary btn-sm">
|
||||
<i class="fas fa-eye"></i> View
|
||||
</a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('community.edit_post', id=post.id) }}"
|
||||
class="btn btn-outline-warning btn-sm">
|
||||
<i class="fas fa-edit"></i> Edit
|
||||
</a>
|
||||
<button type="button" class="btn btn-outline-danger btn-sm"
|
||||
onclick="confirmDelete({{ post.id }}, '{{ post.title|replace("'", "\\'") }}')">
|
||||
<i class="fas fa-trash"></i> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
{% if posts.pages > 1 %}
|
||||
<nav aria-label="Posts pagination" class="mt-4">
|
||||
<ul class="pagination justify-content-center">
|
||||
{% if posts.has_prev %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('community.profile', page=posts.prev_num) }}">
|
||||
<i class="fas fa-chevron-left"></i> Previous
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
|
||||
{% for page_num in posts.iter_pages() %}
|
||||
{% if page_num %}
|
||||
{% if page_num != posts.page %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('community.profile', page=page_num) }}">{{ page_num }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item active">
|
||||
<span class="page-link">{{ page_num }}</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% else %}
|
||||
<li class="page-item disabled">
|
||||
<span class="page-link">...</span>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
|
||||
{% if posts.has_next %}
|
||||
<li class="page-item">
|
||||
<a class="page-link" href="{{ url_for('community.profile', page=posts.next_num) }}">
|
||||
Next <i class="fas fa-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</nav>
|
||||
{% endif %}
|
||||
|
||||
{% else %}
|
||||
<!-- No Posts State -->
|
||||
<div class="text-center py-5">
|
||||
<i class="fas fa-mountain fa-5x text-muted mb-4"></i>
|
||||
<h3 class="text-muted">No Adventures Yet</h3>
|
||||
<p class="text-muted mb-4">You haven't shared any motorcycle adventures yet. Start your journey!</p>
|
||||
<a href="{{ url_for('community.new_post') }}" class="btn btn-primary btn-lg">
|
||||
<i class="fas fa-plus"></i> Create Your First Adventure
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Confirmation Modal -->
|
||||
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header bg-danger text-white">
|
||||
<h5 class="modal-title" id="deleteModalLabel">
|
||||
<i class="fas fa-exclamation-triangle"></i> Confirm Deletion
|
||||
</h5>
|
||||
<button type="button" class="btn-close btn-close-white" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<p>Are you sure you want to delete the adventure:</p>
|
||||
<h6 id="deletePostTitle" class="text-danger"></h6>
|
||||
<p class="text-muted small mb-0">
|
||||
<i class="fas fa-exclamation-circle"></i>
|
||||
This action cannot be undone. All images and GPS tracks will also be deleted.
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times"></i> Cancel
|
||||
</button>
|
||||
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
|
||||
<i class="fas fa-trash"></i> Yes, Delete Adventure
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let deletePostId = null;
|
||||
|
||||
function confirmDelete(postId, postTitle) {
|
||||
deletePostId = postId;
|
||||
document.getElementById('deletePostTitle').textContent = postTitle;
|
||||
const modal = new bootstrap.Modal(document.getElementById('deleteModal'));
|
||||
modal.show();
|
||||
}
|
||||
|
||||
document.getElementById('confirmDeleteBtn').addEventListener('click', function() {
|
||||
if (deletePostId) {
|
||||
const btn = this;
|
||||
const originalText = btn.innerHTML;
|
||||
|
||||
// Show loading state
|
||||
btn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Deleting...';
|
||||
btn.disabled = true;
|
||||
|
||||
// Send delete request
|
||||
fetch(`/community/delete-post/${deletePostId}`, {
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
// Hide modal
|
||||
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteModal'));
|
||||
modal.hide();
|
||||
|
||||
// Show success message and reload page
|
||||
const alert = document.createElement('div');
|
||||
alert.className = 'alert alert-success alert-dismissible fade show';
|
||||
alert.innerHTML = `
|
||||
<i class="fas fa-check-circle"></i> ${data.message}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||
`;
|
||||
document.querySelector('.container-fluid').insertBefore(alert, document.querySelector('.container-fluid').firstChild);
|
||||
|
||||
// Reload page after delay
|
||||
setTimeout(() => {
|
||||
window.location.reload();
|
||||
}, 2000);
|
||||
} else {
|
||||
throw new Error(data.error || 'Failed to delete post');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('An error occurred while deleting the post: ' + error.message);
|
||||
})
|
||||
.finally(() => {
|
||||
// Restore button state
|
||||
btn.innerHTML = originalText;
|
||||
btn.disabled = false;
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
69
app/templates/community/simple_form.html
Normal file
69
app/templates/community/simple_form.html
Normal file
@@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Simple Post Creation Test{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mt-5">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Simple Post Creation Test</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="POST" action="{{ url_for('community.new_post') }}" enctype="multipart/form-data">
|
||||
<div class="mb-3">
|
||||
<label for="title" class="form-label">Title *</label>
|
||||
<input type="text" class="form-control" id="title" name="title" required
|
||||
value="{{ request.args.get('title', '') }}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="subtitle" class="form-label">Subtitle</label>
|
||||
<input type="text" class="form-control" id="subtitle" name="subtitle"
|
||||
value="{{ request.args.get('subtitle', '') }}">
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="difficulty" class="form-label">Difficulty *</label>
|
||||
<select class="form-control" id="difficulty" name="difficulty" required>
|
||||
<option value="">Select difficulty...</option>
|
||||
<option value="1" {% if request.args.get('difficulty') == '1' %}selected{% endif %}>🟢 Easy</option>
|
||||
<option value="2" {% if request.args.get('difficulty') == '2' %}selected{% endif %}>🟡 Moderate</option>
|
||||
<option value="3" {% if request.args.get('difficulty') == '3' %}selected{% endif %}>🟠 Challenging</option>
|
||||
<option value="4" {% if request.args.get('difficulty') == '4' %}selected{% endif %}>🔴 Difficult</option>
|
||||
<option value="5" {% if request.args.get('difficulty') == '5' %}selected{% endif %}>🟣 Expert</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="content" class="form-label">Content</label>
|
||||
<textarea class="form-control" id="content" name="content" rows="4"
|
||||
placeholder="Adventure details (optional for testing)">{{ request.args.get('content', 'Adventure details will be added later.') }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="cover_picture" class="form-label">Cover Picture</label>
|
||||
<input type="file" class="form-control" id="cover_picture" name="cover_picture" accept="image/*">
|
||||
{% if request.args.get('cover_picture') %}
|
||||
<small class="text-muted">Suggested: {{ request.args.get('cover_picture') }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="gpx_file" class="form-label">GPX File</label>
|
||||
<input type="file" class="form-control" id="gpx_file" name="gpx_file" accept=".gpx">
|
||||
{% if request.args.get('gpx_file') %}
|
||||
<small class="text-muted">Suggested: {{ request.args.get('gpx_file') }}</small>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">Create Post</button>
|
||||
<a href="{{ url_for('community.index') }}" class="btn btn-secondary">Cancel</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user