fitpub/src/main/resources/templates/activities/detail.html
2026-04-06 23:41:33 +02:00

1851 lines
93 KiB
HTML

<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<head>
<title>Activity Details</title>
</head>
<body>
<div layout:fragment="content">
<!-- Loading Indicator -->
<div id="loadingIndicator" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2 text-muted">Loading activity...</p>
</div>
<!-- Error Alert -->
<div id="errorAlert" class="alert alert-danger d-none" role="alert">
<i class="bi bi-exclamation-triangle-fill"></i>
<span id="errorMessage"></span>
</div>
<!-- Activity Content -->
<div id="activityContent" class="d-none">
<!-- Activity Header -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-start">
<div>
<h2 id="activityTitle">Activity Title</h2>
<p class="text-muted mb-2">
<span id="activityType" class="activity-type-badge"></span>
<span id="raceBadge" class="badge race-badge ms-2" style="display: none;">
<i class="bi bi-flag-checkered"></i> Race
</span>
<span id="indoorBadge" class="badge bg-warning text-dark ms-2" style="display: none;">
<i class="bi bi-house-door"></i> Indoor
</span>
<span class="ms-2">
<i class="bi bi-calendar"></i>
<span id="activityDate"></span>
</span>
<span class="ms-2">
<i class="bi bi-geo-alt"></i>
<span id="activityLocation"></span>
</span>
<span class="ms-2" id="visibilityBadge">
<i class="bi bi-globe"></i>
<span id="activityVisibility"></span>
</span>
</p>
<p id="activityDescription" class="text-muted"></p>
</div>
<div class="btn-group" role="group" id="activityActions" style="display: none;">
<a href="#" id="editBtn" class="btn btn-outline-primary">
<i class="bi bi-pencil"></i> Edit
</a>
<button id="deleteBtn" class="btn btn-outline-danger">
<i class="bi bi-trash"></i> Delete
</button>
</div>
</div>
</div>
</div>
<!-- Activity Metrics -->
<div class="row mb-4">
<div class="col-12">
<div class="card border shadow-sm">
<div class="card-body">
<div class="row g-2">
<!-- Distance -->
<div class="col-md-3 col-6">
<div class="d-flex align-items-center py-2">
<i class="bi bi-signpost-2 text-primary me-2" style="font-size: 1.5rem;"></i>
<div>
<div class="text-muted small">Distance</div>
<div class="fw-bold" id="metricDistance">--</div>
</div>
</div>
</div>
<!-- Duration -->
<div class="col-md-3 col-6">
<div class="d-flex align-items-center py-2">
<i class="bi bi-clock text-primary me-2" style="font-size: 1.5rem;"></i>
<div>
<div class="text-muted small">Duration</div>
<div class="fw-bold" id="metricDuration">--</div>
</div>
</div>
</div>
<!-- Moving Time -->
<div class="col-md-3 col-6" id="metricMovingTimeContainer" style="display: none;">
<div class="d-flex align-items-center py-2">
<i class="bi bi-clock-history text-primary me-2" style="font-size: 1.5rem;"></i>
<div>
<div class="text-muted small">Moving Time</div>
<div class="fw-bold" id="metricMovingTime">--</div>
</div>
</div>
</div>
<!-- Elevation Gain -->
<div class="col-md-3 col-6">
<div class="d-flex align-items-center py-2">
<i class="bi bi-graph-up-arrow text-primary me-2" style="font-size: 1.5rem;"></i>
<div>
<div class="text-muted small">Elevation</div>
<div class="fw-bold" id="metricElevationGain">--</div>
</div>
</div>
</div>
<!-- Avg Pace -->
<div class="col-md-3 col-6">
<div class="d-flex align-items-center py-2">
<i class="bi bi-speedometer text-primary me-2" style="font-size: 1.5rem;"></i>
<div>
<div class="text-muted small">Avg Pace</div>
<div class="fw-bold" id="metricPace">--</div>
</div>
</div>
</div>
<!-- Avg Speed -->
<div class="col-md-3 col-6" id="metricAvgSpeedContainer" style="display: none;">
<div class="d-flex align-items-center py-2">
<i class="bi bi-speedometer2 text-primary me-2" style="font-size: 1.5rem;"></i>
<div>
<div class="text-muted small">Avg Speed</div>
<div class="fw-bold" id="metricAvgSpeed">--</div>
</div>
</div>
</div>
<!-- Avg Heart Rate -->
<div class="col-md-3 col-6" id="metricAvgHRContainer" style="display: none;">
<div class="d-flex align-items-center py-2">
<i class="bi bi-heart text-danger me-2" style="font-size: 1.5rem;"></i>
<div>
<div class="text-muted small">Avg HR</div>
<div class="fw-bold" id="metricAvgHR">--</div>
</div>
</div>
</div>
<!-- Calories -->
<div class="col-md-3 col-6" id="metricCaloriesContainer" style="display: none;">
<div class="d-flex align-items-center py-2">
<i class="bi bi-fire text-orange me-2" style="font-size: 1.5rem;"></i>
<div>
<div class="text-muted small">Calories</div>
<div class="fw-bold" id="metricCalories">--</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Peaks Card -->
<div class="row mb-4" id="peaksSection" style="display: none;">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-triangle"></i> Peaks
</h5>
</div>
<div class="card-body p-0">
<ul class="list-group list-group-flush" id="peaksList">
</ul>
</div>
</div>
</div>
</div>
<!-- Weather Card -->
<div class="row mb-4" id="weatherSection" style="display: none;">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-cloud-sun"></i> Weather Conditions
</h5>
</div>
<div class="card-body">
<div class="row align-items-center">
<div class="col-md-2 text-center">
<div id="weatherEmoji" style="font-size: 4rem;">🌡️</div>
<div id="weatherCondition" class="text-muted">--</div>
</div>
<div class="col-md-10">
<div class="row">
<div class="col-md-3 mb-2">
<div class="text-muted small">Temperature</div>
<div class="fw-bold fs-4" id="weatherTemp">--</div>
</div>
<div class="col-md-3 mb-2">
<div class="text-muted small">Feels Like</div>
<div class="fw-bold" id="weatherFeelsLike">--</div>
</div>
<div class="col-md-3 mb-2">
<div class="text-muted small">Humidity</div>
<div class="fw-bold" id="weatherHumidity">--</div>
</div>
<div class="col-md-3 mb-2">
<div class="text-muted small">Wind</div>
<div class="fw-bold" id="weatherWind">--</div>
</div>
</div>
<div class="row mt-2">
<div class="col-md-3 mb-2">
<div class="text-muted small">Pressure</div>
<div class="fw-bold" id="weatherPressure">--</div>
</div>
<div class="col-md-3 mb-2">
<div class="text-muted small">Visibility</div>
<div class="fw-bold" id="weatherVisibility">--</div>
</div>
<div class="col-md-3 mb-2">
<div class="text-muted small">Cloudiness</div>
<div class="fw-bold" id="weatherCloudiness">--</div>
</div>
<div class="col-md-3 mb-2" id="weatherPrecipSection" style="display: none;">
<div class="text-muted small">Precipitation</div>
<div class="fw-bold" id="weatherPrecip">--</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Map / Indoor Activity Placeholder -->
<div class="row mb-4" id="mapSection">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-map"></i> Route Map
</h5>
</div>
<div class="card-body p-0">
<div id="activityMap" class="map-container-large"></div>
</div>
</div>
</div>
</div>
<!-- Indoor Activity Placeholder -->
<div class="row mb-4" id="indoorPlaceholder" style="display: none;">
<div class="col-12">
<div class="card">
<div class="card-body text-center py-5">
<div id="activityTypeEmoji" style="font-size: 5rem;" class="mb-3">🏋️</div>
<h4 id="activityTypeName" class="text-muted">Indoor Activity</h4>
<p class="text-muted">No GPS track available</p>
</div>
</div>
</div>
</div>
<!-- Charts Row -->
<div class="row mb-4" id="chartsSection" style="display: none;">
<!-- Elevation Chart -->
<div class="col-lg-4 col-md-12 mb-3 mb-lg-0" id="elevationSection" style="display: none;">
<div class="card h-100">
<div class="card-header py-2">
<h6 class="mb-0">
<i class="bi bi-graph-up"></i> Elevation
</h6>
</div>
<div class="card-body">
<canvas id="elevationChart" height="120"></canvas>
</div>
</div>
</div>
<!-- Heart Rate Chart -->
<div class="col-lg-4 col-md-12 mb-3 mb-lg-0" id="heartRateSection" style="display: none;">
<div class="card h-100">
<div class="card-header py-2">
<h6 class="mb-0">
<i class="bi bi-heart-pulse"></i> Heart Rate
</h6>
</div>
<div class="card-body">
<canvas id="heartRateChart" height="120"></canvas>
</div>
</div>
</div>
<!-- Speed/Pace Chart -->
<div class="col-lg-4 col-md-12 mb-3 mb-lg-0" id="speedSection" style="display: none;">
<div class="card h-100">
<div class="card-header py-2">
<h6 class="mb-0">
<i class="bi bi-speedometer2"></i> Speed/Pace
</h6>
</div>
<div class="card-body">
<canvas id="speedChart" height="120"></canvas>
</div>
</div>
</div>
</div>
<!-- Additional Metrics -->
<div class="row mb-4" id="additionalMetrics" style="display: none;">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-speedometer2"></i> Additional Metrics
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4 mb-3" id="avgHeartRateContainer" style="display: none;">
<strong>Average Heart Rate:</strong>
<span id="avgHeartRate" class="float-end">-- bpm</span>
</div>
<div class="col-md-4 mb-3" id="maxHeartRateContainer" style="display: none;">
<strong>Max Heart Rate:</strong>
<span id="maxHeartRate" class="float-end">-- bpm</span>
</div>
<div class="col-md-4 mb-3" id="avgCadenceContainer" style="display: none;">
<strong>Average Cadence:</strong>
<span id="avgCadence" class="float-end">-- rpm</span>
</div>
<div class="col-md-4 mb-3" id="avgSpeedContainer" style="display: none;">
<strong>Average Speed:</strong>
<span id="avgSpeed" class="float-end">-- km/h</span>
</div>
<div class="col-md-4 mb-3" id="maxSpeedContainer" style="display: none;">
<strong>Max Speed:</strong>
<span id="maxSpeed" class="float-end">-- km/h</span>
</div>
<div class="col-md-4 mb-3" id="caloriesContainer" style="display: none;">
<strong>Calories:</strong>
<span id="calories" class="float-end">-- kcal</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Social Interactions -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="bi bi-chat-heart"></i> Social
</h5>
<div>
<button id="likeBtn" class="btn btn-sm btn-outline-danger me-2">
<i class="bi bi-heart"></i>
<span id="likeBtnText">Like</span>
(<span id="likeCount">0</span>)
</button>
</div>
</div>
<div class="card-body">
<!-- Likes Section -->
<div class="mb-4" id="likesSection">
<h6 class="text-muted mb-3">
<i class="bi bi-heart-fill text-danger"></i>
Liked by <span id="likesCountText">0</span>
</h6>
<div id="likesList" class="d-flex flex-wrap gap-2">
<!-- Likes will be populated here -->
</div>
</div>
<!-- Comments Section -->
<div>
<h6 class="text-muted mb-3">
<i class="bi bi-chat-left-text"></i>
Comments (<span id="commentsCount">0</span>)
</h6>
<!-- Comment Form -->
<div id="commentForm" class="mb-4" style="display: none;">
<form id="addCommentForm">
<div class="mb-3">
<textarea
id="commentContent"
class="form-control"
rows="3"
placeholder="Write a comment..."
required
></textarea>
</div>
<button type="submit" class="btn btn-primary btn-sm">
<i class="bi bi-send"></i> Post Comment
</button>
</form>
</div>
<!-- Comments List -->
<div id="commentsList">
<!-- Comments will be populated here -->
</div>
<!-- Login prompt for non-authenticated users -->
<div id="loginPrompt" style="display: none;">
<p class="text-muted">
<a href="/login">Log in</a> to like or comment on this activity.
</p>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Back Button -->
<div class="row">
<div class="col-12">
<a th:href="@{/activities}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Activities
</a>
</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">
<h5 class="modal-title" id="deleteModalLabel">
<i class="bi bi-exclamation-triangle text-danger"></i>
Delete Activity
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete this activity?</p>
<p class="text-danger mb-0"><strong>This action cannot be undone.</strong></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
<i class="bi bi-trash"></i> Delete
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Custom Scripts -->
<th:block layout:fragment="scripts">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
const activityId = window.location.pathname.split('/').pop();
const loadingIndicator = document.getElementById('loadingIndicator');
const errorAlert = document.getElementById('errorAlert');
const errorMessage = document.getElementById('errorMessage');
const activityContent = document.getElementById('activityContent');
// Global variables for map interaction
let activityMap = null;
let hoverMarker = null;
let currentTrackPoints = null;
/**
* Throttle function to limit how often a function can be called
* @param {Function} func - Function to throttle
* @param {number} limit - Minimum time between calls in milliseconds
* @returns {Function} Throttled function
*/
function throttle(func, limit) {
let inThrottle;
return function(...args) {
if (!inThrottle) {
func.apply(this, args);
inThrottle = true;
setTimeout(() => inThrottle = false, limit);
}
};
}
// Load activity details
loadActivity();
async function loadActivity() {
try {
// Use authenticated fetch if user is logged in, otherwise regular fetch
// This allows public activities to be viewed without authentication
const response = FitPubAuth.isAuthenticated()
? await FitPubAuth.authenticatedFetch(`/api/activities/${activityId}`)
: await fetch(`/api/activities/${activityId}`);
if (response.ok) {
const activity = await response.json();
renderActivity(activity);
// Hide loading, show content
loadingIndicator.classList.add('d-none');
activityContent.classList.remove('d-none');
} else {
throw new Error('Failed to load activity');
}
} catch (error) {
console.error('Error loading activity:', error);
loadingIndicator.classList.add('d-none');
errorMessage.textContent = 'Failed to load activity. Please try again.';
errorAlert.classList.remove('d-none');
}
}
function renderActivity(activity) {
// Header
document.getElementById('activityTitle').textContent = activity.title || 'Untitled Activity';
document.getElementById('activityType').textContent = activity.activityType;
document.getElementById('activityType').className = `activity-type-badge activity-type-${activity.activityType.toLowerCase()}`;
// Format date with timezone awareness
document.getElementById('activityDate').textContent = FitPub.formatDateTimeWithTimezone(
activity.startedAt,
activity.timezone || 'UTC'
);
document.getElementById('activityLocation').textContent = activity.activityLocation;
document.getElementById('activityVisibility').textContent = activity.visibility;
// Visibility icon
const visIcon = getVisibilityIcon(activity.visibility);
document.querySelector('#visibilityBadge i').className = `bi bi-${visIcon}`;
document.getElementById('visibilityBadge').className = `ms-2 visibility-${activity.visibility.toLowerCase()}`;
// Race badge and styling
const raceBadge = document.getElementById('raceBadge');
const activityTypeSpan = document.getElementById('activityType');
const activityContent = document.getElementById('activityContent');
const metricsCard = document.querySelector('#activityContent .card.border');
if (activity.race === true) {
raceBadge.style.display = 'inline-block';
activityTypeSpan.classList.add('race-activity');
activityContent.classList.add('race-detail');
if (metricsCard) {
metricsCard.classList.add('race-metrics');
}
} else {
raceBadge.style.display = 'none';
activityTypeSpan.classList.remove('race-activity');
activityContent.classList.remove('race-detail');
if (metricsCard) {
metricsCard.classList.remove('race-metrics');
}
}
// Indoor badge
const indoorBadge = document.getElementById('indoorBadge');
if (activity.indoor === true) {
indoorBadge.style.display = 'inline-block';
if (activity.indoorDetectionMethod) {
indoorBadge.title = `Detected via: ${activity.indoorDetectionMethod}`;
}
} else {
indoorBadge.style.display = 'none';
}
// Description
if (activity.description) {
document.getElementById('activityDescription').textContent = activity.description;
} else {
document.getElementById('activityDescription').style.display = 'none';
}
// Show Edit/Delete buttons only if user is logged in and owns the activity
if (FitPubAuth.isAuthenticated()) {
// Fetch current user to check if they own this activity
checkActivityOwnership(activity);
}
// Check if activity has GPS track
const hasGpsTrack = activity.hasGpsTrack === true;
// Metrics - Conditional based on GPS availability
if (hasGpsTrack) {
// Show GPS-related metrics
document.getElementById('metricDistance').textContent = formatDistance(activity.totalDistance);
document.getElementById('metricElevationGain').textContent = activity.elevationGain ? Math.round(activity.elevationGain) + ' m' : 'N/A';
// Calculate pace
if (activity.totalDistance && activity.totalDuration) {
const paceSeconds = activity.totalDuration / (activity.totalDistance / 1000);
document.getElementById('metricPace').textContent = formatPace(paceSeconds);
}
} else {
// Hide GPS-related metrics for indoor activities
document.getElementById('metricDistance').parentElement.parentElement.parentElement.style.display = 'none';
document.getElementById('metricElevationGain').parentElement.parentElement.parentElement.style.display = 'none';
document.getElementById('metricPace').parentElement.parentElement.parentElement.style.display = 'none';
}
// Duration is always shown
document.getElementById('metricDuration').textContent = formatDuration(activity.totalDuration);
// Moving Time - only show if available and different from total duration
if (activity.movingTimeSeconds != null && activity.movingTimeSeconds < activity.totalDuration) {
document.getElementById('metricMovingTimeContainer').style.display = 'block';
document.getElementById('metricMovingTime').textContent = formatDuration(activity.movingTimeSeconds);
}
// Additional Metrics (conditional)
// Note: averageSpeed is already in km/h from backend (converted in FitParser)
if (activity.averageSpeed && hasGpsTrack) {
document.getElementById('metricAvgSpeedContainer').style.display = 'block';
document.getElementById('metricAvgSpeed').textContent = parseFloat(activity.averageSpeed).toFixed(1) + ' km/h';
}
if (activity.averageHeartRate) {
document.getElementById('metricAvgHRContainer').style.display = 'block';
document.getElementById('metricAvgHR').textContent = activity.averageHeartRate + ' bpm';
}
if (activity.calories) {
document.getElementById('metricCaloriesContainer').style.display = 'block';
document.getElementById('metricCalories').textContent = activity.calories + ' kcal';
}
// Render map or indoor placeholder
if (hasGpsTrack && activity.trackPoints.length > 0) {
document.getElementById('mapSection').style.display = 'block';
document.getElementById('indoorPlaceholder').style.display = 'none';
renderMap(activity.trackPoints, activity);
} else {
// Show indoor activity placeholder
document.getElementById('mapSection').style.display = 'none';
document.getElementById('indoorPlaceholder').style.display = 'block';
showIndoorPlaceholder(activity.activityType);
}
// Load weather data (only for outdoor activities)
if (hasGpsTrack) {
loadWeatherData(activity.id);
}
// Render peaks
if (activity.peaks && activity.peaks.length > 0) {
const peaksList = document.getElementById('peaksList');
peaksList.innerHTML = activity.peaks.map(peak => {
const content = peak.wikipedia
? `<a href="${peak.wikipedia}" target="_blank" rel="noopener">${peak.name} <i class="bi bi-box-arrow-up-right small"></i></a>`
: peak.name;
return `<li class="list-group-item">${content}</li>`;
}).join('');
document.getElementById('peaksSection').style.display = 'block';
}
// Render elevation chart if data exists
if (activity.trackPoints && activity.trackPoints.length > 0) {
// Store track points globally for map marker updates
currentTrackPoints = activity.trackPoints;
const hasElevation = activity.trackPoints.some(p => p.elevation != null);
if (hasElevation) {
document.getElementById('elevationSection').style.display = 'block';
renderElevationChart(activity.trackPoints);
}
// Render heart rate chart if data exists
const hasHeartRate = activity.trackPoints.some(p => p.heartRate != null && p.heartRate > 0);
if (hasHeartRate) {
document.getElementById('heartRateSection').style.display = 'block';
renderHeartRateChart(activity.trackPoints);
}
// Render speed/pace chart if data exists
const hasSpeed = activity.trackPoints.some(p => p.speed != null && p.speed > 0);
if (hasSpeed) {
document.getElementById('speedSection').style.display = 'block';
renderSpeedChart(activity.trackPoints);
}
// Show charts section if at least one chart is visible
if (hasElevation || hasHeartRate || hasSpeed) {
document.getElementById('chartsSection').style.display = 'flex';
}
}
// Additional metrics
renderAdditionalMetrics(activity);
// Load social interactions
loadLikes();
loadComments();
setupSocialInteractions(activity);
}
/**
* Check if the current user owns this activity and show edit/delete buttons if so.
*/
async function checkActivityOwnership(activity) {
try {
const response = await FitPubAuth.authenticatedFetch('/api/users/me');
if (response.ok) {
const currentUser = await response.json();
if (currentUser.id === activity.userId) {
document.getElementById('activityActions').style.display = 'block';
document.getElementById('editBtn').href = `/activities/${activity.id}/edit`;
}
}
} catch (error) {
console.error('Error checking activity ownership:', error);
// Don't show buttons if there's an error
}
}
async function loadWeatherData(activityId) {
try {
const response = await fetch(`/api/activities/${activityId}/weather`);
if (response.ok) {
const weather = await response.json();
displayWeather(weather);
} else if (response.status === 404) {
// No weather data available, hide section
console.debug('No weather data available for activity');
}
} catch (error) {
console.error('Error loading weather data:', error);
// Silently fail - weather is optional
}
}
function displayWeather(weather) {
// Show weather section
document.getElementById('weatherSection').style.display = 'block';
// Display emoji and condition
document.getElementById('weatherEmoji').textContent = weather.weatherEmoji || '🌡️';
document.getElementById('weatherCondition').textContent = weather.weatherDescription || weather.weatherCondition || '--';
// Temperature
if (weather.temperatureCelsius != null) {
document.getElementById('weatherTemp').textContent = Math.round(weather.temperatureCelsius) + '°C';
}
// Feels like
if (weather.feelsLikeCelsius != null) {
document.getElementById('weatherFeelsLike').textContent = Math.round(weather.feelsLikeCelsius) + '°C';
}
// Humidity
if (weather.humidity != null) {
document.getElementById('weatherHumidity').textContent = weather.humidity + '%';
}
// Wind
if (weather.windSpeedKmh != null) {
const windText = Math.round(weather.windSpeedKmh) + ' km/h';
const direction = weather.windDirectionCardinal ? ' ' + weather.windDirectionCardinal : '';
document.getElementById('weatherWind').textContent = windText + direction;
}
// Pressure
if (weather.pressure != null) {
document.getElementById('weatherPressure').textContent = weather.pressure + ' hPa';
}
// Visibility
if (weather.visibilityMeters != null) {
const visibilityKm = (weather.visibilityMeters / 1000).toFixed(1);
document.getElementById('weatherVisibility').textContent = visibilityKm + ' km';
}
// Cloudiness
if (weather.cloudiness != null) {
document.getElementById('weatherCloudiness').textContent = weather.cloudiness + '%';
}
// Precipitation
if (weather.precipitationMm != null && weather.precipitationMm > 0) {
document.getElementById('weatherPrecipSection').style.display = 'block';
document.getElementById('weatherPrecip').textContent = weather.precipitationMm.toFixed(1) + ' mm';
}
}
function flattenTrackPoints(trackPoints) {
return trackPoints.map(coordinates => ([coordinates.longitude, coordinates.latitude]));
}
function renderMap(trackPoints, activity) {
// Parse GeoJSON from simplifiedTrack
const geoJson = {
type: 'LineString',
coordinates: flattenTrackPoints(trackPoints)
};
// Create map (needs to be done after container is visible)
setTimeout(() => {
activityMap = FitPub.createActivityMap('activityMap', geoJson, {
showStartEnd: false, // Privacy: Do not show start/end markers
fitBounds: true
});
// Create a hover marker (initially hidden)
if (activityMap) {
const pulsingIcon = L.divIcon({
className: 'chart-hover-marker',
html: '<div class="marker-pulse"></div>',
iconSize: [20, 20],
iconAnchor: [10, 10]
});
hoverMarker = L.marker([0, 0], {
icon: pulsingIcon,
opacity: 0
}).addTo(activityMap);
// Add CSS for the pulsing marker
if (!document.getElementById('chart-hover-marker-style')) {
const style = document.createElement('style');
style.id = 'chart-hover-marker-style';
style.textContent = `
.chart-hover-marker {
background: transparent;
border: none;
}
.marker-pulse {
width: 20px;
height: 20px;
border-radius: 50%;
background: rgba(255, 69, 0, 0.8);
border: 3px solid white;
box-shadow: 0 0 10px rgba(255, 69, 0, 0.6);
animation: pulse 1.5s ease-in-out infinite;
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 0.8; }
50% { transform: scale(1.2); opacity: 1; }
}
`;
document.head.appendChild(style);
}
}
// Render privacy zones if present (owner view only)
console.log('Privacy zones in response:', activity.privacyZones);
if (activity.privacyZones && activity.privacyZones.length > 0) {
console.log('Rendering', activity.privacyZones.length, 'privacy zones');
renderPrivacyZones(activityMap, activity.privacyZones);
} else {
console.log('No privacy zones to render');
}
// Force fit bounds again after map is fully rendered
if (activityMap && activityMap.trackLayer) {
setTimeout(() => {
try {
const bounds = activityMap.trackLayer.getBounds();
if (bounds.isValid()) {
activityMap.fitBounds(bounds, { padding: [50, 50] });
}
} catch (e) {
console.warn('Could not fit bounds on second attempt:', e);
}
}, 200);
}
}, 50);
}
function renderPrivacyZones(map, zones) {
// Helper to escape HTML
const escapeHtml = (text) => {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
};
// Render each privacy zone as a translucent red circle
zones.forEach(zone => {
const circle = L.circle([zone.latitude, zone.longitude], {
color: '#dc3545',
fillColor: '#dc3545',
fillOpacity: 0.1,
opacity: 0.4,
weight: 2,
dashArray: '5, 10',
radius: zone.radiusMeters
}).addTo(map);
// Add tooltip
const zoneName = escapeHtml(zone.name || 'Privacy Zone');
circle.bindTooltip(
`<strong>🔒 Privacy Zone</strong><br>${zoneName}<br><small class="text-muted">Hidden for others</small>`,
{
permanent: false,
direction: 'top',
className: 'privacy-zone-tooltip'
}
);
});
// Add CSS for privacy zone tooltip
if (!document.getElementById('privacy-zone-tooltip-style')) {
const style = document.createElement('style');
style.id = 'privacy-zone-tooltip-style';
style.textContent = `
.privacy-zone-tooltip {
background: rgba(220, 53, 69, 0.95);
color: white;
border: none;
border-radius: 4px;
padding: 8px 12px;
font-size: 13px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
}
.privacy-zone-tooltip::before {
border-top-color: rgba(220, 53, 69, 0.95);
}
`;
document.head.appendChild(style);
}
}
function renderElevationChart(trackPoints) {
// Calculate cumulative distance and prepare elevation data
let cumulativeDistance = 0;
const elevationData = [];
for (let i = 0; i < trackPoints.length; i++) {
const point = trackPoints[i];
// Calculate distance from previous point (simple Haversine approximation)
if (i > 0 && point.latitude && point.longitude) {
const prev = trackPoints[i - 1];
if (prev.latitude && prev.longitude) {
const distance = calculateDistance(
prev.latitude, prev.longitude,
point.latitude, point.longitude
);
cumulativeDistance += distance;
}
}
// Add point if it has elevation data
if (point.elevation != null) {
elevationData.push({
distance: cumulativeDistance,
elevation: point.elevation,
trackPointIndex: i // Store the original track point index
});
}
}
if (elevationData.length > 0) {
// Smooth elevation data to remove zero/invalid values
const smoothedData = smoothElevationData(elevationData);
// Create throttled hover handler for elevation chart
const elevationHoverHandler = throttle((event, activeElements) => {
if (activeElements && activeElements.length > 0) {
const dataIndex = activeElements[0].index;
if (smoothedData[dataIndex]) {
updateMapMarker(smoothedData[dataIndex].trackPointIndex);
}
} else {
hideMapMarker();
}
}, 50); // 50ms throttle
// Create elevation chart with hover interaction
const ctx = document.getElementById('elevationChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: smoothedData.map(d => (d.distance / 1000).toFixed(2)),
datasets: [{
label: 'Elevation (m)',
data: smoothedData.map(d => d.elevation),
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: 0,
pointHoverRadius: 0 // Disable hover radius for better performance
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
onHover: elevationHoverHandler,
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
},
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
title: (context) => {
return `Distance: ${context[0].label} km`;
},
label: (context) => {
return `Elevation: ${context.parsed.y.toFixed(1)} m`;
}
}
}
},
scales: {
x: {
title: {
display: true,
text: 'Distance (km)'
}
},
y: {
title: {
display: true,
text: 'Elevation (m)'
}
}
}
}
});
}
}
/**
* Smooth elevation data by interpolating zero/invalid values and applying moving average
* @param {Array} data - Array of {distance, elevation, trackPointIndex} objects
* @returns {Array} Smoothed elevation data
*/
function smoothElevationData(data) {
if (data.length === 0) return data;
// Step 1: Replace zeros and invalid values with interpolated values
const interpolated = [...data];
for (let i = 0; i < interpolated.length; i++) {
if (interpolated[i].elevation === 0 || interpolated[i].elevation == null) {
// Find previous valid value
let prevIndex = i - 1;
while (prevIndex >= 0 && (interpolated[prevIndex].elevation === 0 || interpolated[prevIndex].elevation == null)) {
prevIndex--;
}
// Find next valid value
let nextIndex = i + 1;
while (nextIndex < interpolated.length && (interpolated[nextIndex].elevation === 0 || interpolated[nextIndex].elevation == null)) {
nextIndex++;
}
// Interpolate between valid values
if (prevIndex >= 0 && nextIndex < interpolated.length) {
const prevElevation = interpolated[prevIndex].elevation;
const nextElevation = interpolated[nextIndex].elevation;
const ratio = (i - prevIndex) / (nextIndex - prevIndex);
interpolated[i].elevation = prevElevation + (nextElevation - prevElevation) * ratio;
} else if (prevIndex >= 0) {
// Use previous value if no next value available
interpolated[i].elevation = interpolated[prevIndex].elevation;
} else if (nextIndex < interpolated.length) {
// Use next value if no previous value available
interpolated[i].elevation = interpolated[nextIndex].elevation;
}
}
}
// Step 2: Apply moving average smoothing (window size 5)
const windowSize = 5;
const smoothed = [];
for (let i = 0; i < interpolated.length; i++) {
const start = Math.max(0, i - Math.floor(windowSize / 2));
const end = Math.min(interpolated.length, i + Math.ceil(windowSize / 2));
let sum = 0;
let count = 0;
for (let j = start; j < end; j++) {
if (interpolated[j].elevation != null && interpolated[j].elevation !== 0) {
sum += interpolated[j].elevation;
count++;
}
}
smoothed.push({
distance: interpolated[i].distance,
elevation: count > 0 ? sum / count : interpolated[i].elevation,
trackPointIndex: interpolated[i].trackPointIndex // Preserve track point index
});
}
return smoothed;
}
/**
* Update the hover marker position on the map
* @param {number} trackPointIndex - Index of the track point to show
*/
function updateMapMarker(trackPointIndex) {
if (!activityMap || !hoverMarker || !currentTrackPoints) return;
const point = currentTrackPoints[trackPointIndex];
if (point && point.latitude != null && point.longitude != null) {
hoverMarker.setLatLng([point.latitude, point.longitude]);
hoverMarker.setOpacity(1);
}
}
/**
* Hide the hover marker on the map
*/
function hideMapMarker() {
if (hoverMarker) {
hoverMarker.setOpacity(0);
}
}
/**
* Format elapsed time in minutes to mm:ss or hh:mm:ss
* @param {number} minutes - Elapsed time in decimal minutes
* @param {number} totalMinutes - Total duration in minutes (to determine if hours are needed)
* @returns {string} Formatted time string
*/
function formatElapsedTime(minutes, totalMinutes) {
const totalSeconds = Math.floor(minutes * 60);
const hours = Math.floor(totalSeconds / 3600);
const mins = Math.floor((totalSeconds % 3600) / 60);
const secs = totalSeconds % 60;
// Use hh:mm:ss format if total duration is 1 hour or more
if (totalMinutes >= 60) {
return `${hours.toString().padStart(2, '0')}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
} else {
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
}
}
/**
* Render heart rate chart over time
* @param {Array} trackPoints - Array of track point objects
*/
function renderHeartRateChart(trackPoints) {
// Calculate elapsed time and prepare heart rate data
const heartRateData = [];
let startTime = null;
for (let i = 0; i < trackPoints.length; i++) {
const point = trackPoints[i];
if (point.heartRate != null && point.heartRate > 0) {
// Parse timestamp
const timestamp = new Date(point.timestamp);
if (startTime === null) {
startTime = timestamp;
}
// Calculate elapsed time in minutes
const elapsedMinutes = (timestamp - startTime) / 1000 / 60;
heartRateData.push({
time: elapsedMinutes,
heartRate: point.heartRate,
trackPointIndex: i // Store the original track point index
});
}
}
if (heartRateData.length > 0) {
// Calculate total duration to determine time format
const totalMinutes = heartRateData[heartRateData.length - 1].time;
// Create throttled hover handler for heart rate chart
const heartRateHoverHandler = throttle((event, activeElements) => {
if (activeElements && activeElements.length > 0) {
const dataIndex = activeElements[0].index;
if (heartRateData[dataIndex]) {
updateMapMarker(heartRateData[dataIndex].trackPointIndex);
}
} else {
hideMapMarker();
}
}, 50); // 50ms throttle
// Create heart rate chart using Chart.js
const ctx = document.getElementById('heartRateChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: heartRateData.map(d => formatElapsedTime(d.time, totalMinutes)),
datasets: [{
label: 'Heart Rate (bpm)',
data: heartRateData.map(d => d.heartRate),
borderColor: 'rgb(220, 53, 69)',
backgroundColor: 'rgba(220, 53, 69, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: 0,
pointHoverRadius: 0 // Disable hover radius for better performance
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
onHover: heartRateHoverHandler,
plugins: {
legend: {
display: false
},
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
title: function(context) {
return 'Time: ' + context[0].label;
},
label: function(context) {
return context.parsed.y + ' bpm';
}
}
}
},
scales: {
x: {
title: {
display: true,
text: 'Time'
},
ticks: {
maxTicksLimit: 10
}
},
y: {
title: {
display: true,
text: 'Heart Rate (bpm)'
},
beginAtZero: false
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
}
}
});
}
}
/**
* Render speed/pace chart over time
* @param {Array} trackPoints - Array of track point objects
*/
function renderSpeedChart(trackPoints) {
// Calculate elapsed time and prepare speed data
const speedData = [];
let startTime = null;
for (let i = 0; i < trackPoints.length; i++) {
const point = trackPoints[i];
if (point.speed != null && point.speed > 0) {
// Parse timestamp
const timestamp = new Date(point.timestamp);
if (startTime === null) {
startTime = timestamp;
}
// Calculate elapsed time in minutes
const elapsedMinutes = (timestamp - startTime) / 1000 / 60;
// Speed is already in km/h from the FIT parser (converted during parsing)
const speedKmh = point.speed;
speedData.push({
time: elapsedMinutes,
speed: speedKmh,
trackPointIndex: i // Store the original track point index
});
}
}
if (speedData.length > 0) {
// Apply moving average smoothing to speed data (window size 5)
const smoothedSpeedData = smoothSpeedData(speedData);
// Calculate total duration to determine time format
const totalMinutes = smoothedSpeedData[smoothedSpeedData.length - 1].time;
// Create throttled hover handler for speed chart
const speedHoverHandler = throttle((event, activeElements) => {
if (activeElements && activeElements.length > 0) {
const dataIndex = activeElements[0].index;
if (smoothedSpeedData[dataIndex]) {
updateMapMarker(smoothedSpeedData[dataIndex].trackPointIndex);
}
} else {
hideMapMarker();
}
}, 50); // 50ms throttle
// Create speed chart using Chart.js
const ctx = document.getElementById('speedChart').getContext('2d');
new Chart(ctx, {
type: 'line',
data: {
labels: smoothedSpeedData.map(d => formatElapsedTime(d.time, totalMinutes)),
datasets: [{
label: 'Speed (km/h)',
data: smoothedSpeedData.map(d => d.speed),
borderColor: 'rgb(13, 110, 253)',
backgroundColor: 'rgba(13, 110, 253, 0.1)',
borderWidth: 2,
fill: true,
tension: 0.3,
pointRadius: 0,
pointHoverRadius: 0 // Disable hover radius for better performance
}]
},
options: {
responsive: true,
maintainAspectRatio: true,
onHover: speedHoverHandler,
plugins: {
legend: {
display: false
},
tooltip: {
mode: 'index',
intersect: false,
callbacks: {
title: function(context) {
return 'Time: ' + context[0].label;
},
label: function(context) {
const speedKmh = context.parsed.y;
// Calculate pace (min/km)
const paceMinPerKm = speedKmh > 0 ? 60 / speedKmh : 0;
const paceMin = Math.floor(paceMinPerKm);
const paceSec = Math.round((paceMinPerKm - paceMin) * 60);
return `${speedKmh.toFixed(1)} km/h (${paceMin}:${paceSec.toString().padStart(2, '0')} /km)`;
}
}
}
},
scales: {
x: {
title: {
display: true,
text: 'Time'
},
ticks: {
maxTicksLimit: 10
}
},
y: {
title: {
display: true,
text: 'Speed (km/h)'
},
beginAtZero: true
}
},
interaction: {
mode: 'nearest',
axis: 'x',
intersect: false
}
}
});
}
}
/**
* Smooth speed data by applying moving average
* @param {Array} data - Array of {time, speed, trackPointIndex} objects
* @returns {Array} Smoothed speed data
*/
function smoothSpeedData(data) {
if (data.length === 0) return data;
const windowSize = 5;
const smoothed = [];
for (let i = 0; i < data.length; i++) {
const start = Math.max(0, i - Math.floor(windowSize / 2));
const end = Math.min(data.length, i + Math.ceil(windowSize / 2));
let sum = 0;
let count = 0;
for (let j = start; j < end; j++) {
if (data[j].speed > 0) {
sum += data[j].speed;
count++;
}
}
smoothed.push({
time: data[i].time,
speed: count > 0 ? sum / count : data[i].speed,
trackPointIndex: data[i].trackPointIndex // Preserve track point index
});
}
return smoothed;
}
// Haversine formula to calculate distance between two GPS points
function calculateDistance(lat1, lon1, lat2, lon2) {
const R = 6371000; // Earth's radius in meters
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
function renderAdditionalMetrics(activity) {
let hasAdditionalMetrics = false;
// Average Heart Rate
if (activity.averageHeartRate) {
document.getElementById('avgHeartRate').textContent = Math.round(activity.averageHeartRate) + ' bpm';
document.getElementById('avgHeartRateContainer').style.display = 'block';
hasAdditionalMetrics = true;
}
// Max Heart Rate
if (activity.maxHeartRate) {
document.getElementById('maxHeartRate').textContent = Math.round(activity.maxHeartRate) + ' bpm';
document.getElementById('maxHeartRateContainer').style.display = 'block';
hasAdditionalMetrics = true;
}
// Average Cadence
if (activity.averageCadence) {
document.getElementById('avgCadence').textContent = Math.round(activity.averageCadence) + ' rpm';
document.getElementById('avgCadenceContainer').style.display = 'block';
hasAdditionalMetrics = true;
}
// Average Speed (already in km/h from backend)
if (activity.averageSpeed) {
document.getElementById('avgSpeed').textContent = parseFloat(activity.averageSpeed).toFixed(1) + ' km/h';
document.getElementById('avgSpeedContainer').style.display = 'block';
hasAdditionalMetrics = true;
}
// Max Speed (already in km/h from backend)
if (activity.maxSpeed) {
document.getElementById('maxSpeed').textContent = parseFloat(activity.maxSpeed).toFixed(1) + ' km/h';
document.getElementById('maxSpeedContainer').style.display = 'block';
hasAdditionalMetrics = true;
}
// Calories
if (activity.calories) {
document.getElementById('calories').textContent = Math.round(activity.calories) + ' kcal';
document.getElementById('caloriesContainer').style.display = 'block';
hasAdditionalMetrics = true;
}
if (hasAdditionalMetrics) {
document.getElementById('additionalMetrics').style.display = 'block';
}
}
// Social interactions functionality
async function loadLikes() {
try {
const response = await fetch(`/api/activities/${activityId}/likes`);
if (response.ok) {
const likes = await response.json();
renderLikes(likes);
}
} catch (error) {
console.error('Error loading likes:', error);
}
}
function renderLikes(likes) {
const likesList = document.getElementById('likesList');
const likeCount = document.getElementById('likeCount');
const likesCountText = document.getElementById('likesCountText');
likeCount.textContent = likes.length;
likesCountText.textContent = likes.length;
if (likes.length === 0) {
likesList.innerHTML = '<span class="text-muted">No likes yet</span>';
return;
}
likesList.innerHTML = likes.map(like => {
const displayName = like.displayName || like.username || 'Unknown';
const avatarHtml = like.avatarUrl
? `<img src="${like.avatarUrl}" alt="${displayName}" class="rounded-circle me-2" width="32" height="32">`
: `<i class="bi bi-person-circle me-2" style="font-size: 32px;"></i>`;
return `
<div class="d-flex align-items-center p-2 border rounded">
${avatarHtml}
<span>${displayName}</span>
</div>
`;
}).join('');
}
async function loadComments() {
try {
const response = await fetch(`/api/activities/${activityId}/comments`);
if (response.ok) {
const commentsPage = await response.json();
renderComments(commentsPage.content || []);
}
} catch (error) {
console.error('Error loading comments:', error);
}
}
function renderComments(comments) {
const commentsList = document.getElementById('commentsList');
const commentsCount = document.getElementById('commentsCount');
commentsCount.textContent = comments.length;
if (comments.length === 0) {
commentsList.innerHTML = '<p class="text-muted">No comments yet. Be the first to comment!</p>';
return;
}
commentsList.innerHTML = comments.map(comment => {
const displayName = comment.displayName || comment.username || 'Unknown';
const avatarHtml = comment.avatarUrl
? `<img src="${comment.avatarUrl}" alt="${displayName}" class="rounded-circle" width="40" height="40">`
: `<i class="bi bi-person-circle" style="font-size: 40px;"></i>`;
const createdAt = new Date(comment.createdAt).toLocaleString();
const deleteBtn = comment.canDelete
? `<button class="btn btn-sm btn-outline-danger delete-comment-btn" data-comment-id="${comment.id}">
<i class="bi bi-trash"></i>
</button>`
: '';
return `
<div class="d-flex mb-3 pb-3 border-bottom" data-comment-id="${comment.id}">
<div class="me-3">
${avatarHtml}
</div>
<div class="flex-grow-1">
<div class="d-flex justify-content-between align-items-start">
<div>
<strong>${displayName}</strong>
<small class="text-muted ms-2">${createdAt}</small>
</div>
${deleteBtn}
</div>
<p class="mb-0 mt-1">${escapeHtml(comment.content)}</p>
</div>
</div>
`;
}).join('');
// Add delete event listeners
document.querySelectorAll('.delete-comment-btn').forEach(btn => {
btn.addEventListener('click', async function() {
if (confirm('Delete this comment?')) {
await deleteComment(this.dataset.commentId);
}
});
});
}
function setupSocialInteractions(activity) {
const isAuthenticated = FitPubAuth.isAuthenticated();
if (isAuthenticated) {
// Show comment form for authenticated users
document.getElementById('commentForm').style.display = 'block';
// Update like button based on activity data
if (activity.likedByCurrentUser) {
updateLikeButton(true);
}
// Setup like button click handler
document.getElementById('likeBtn').addEventListener('click', handleLikeClick);
// Show comment form for authenticated users
document.getElementById('commentForm').style.display = 'block';
// Setup comment form submit handler
document.getElementById('addCommentForm').addEventListener('submit', handleCommentSubmit);
} else {
// Show login prompt for non-authenticated users
document.getElementById('loginPrompt').style.display = 'block';
// Hide like button for non-authenticated users
document.getElementById('likeBtn').style.display = 'none';
}
}
async function handleLikeClick(event) {
event.preventDefault();
const btn = event.currentTarget;
const isLiked = btn.classList.contains('btn-danger');
try {
if (isLiked) {
// Unlike
const response = await FitPubAuth.authenticatedFetch(
`/api/activities/${activityId}/likes`,
{ method: 'DELETE' }
);
if (response.ok) {
updateLikeButton(false);
loadLikes(); // Reload likes list
}
} else {
// Like
const response = await FitPubAuth.authenticatedFetch(
`/api/activities/${activityId}/likes`,
{ method: 'POST' }
);
if (response.ok) {
updateLikeButton(true);
loadLikes(); // Reload likes list
}
}
} catch (error) {
console.error('Error toggling like:', error);
FitPub.showAlert('Failed to update like. Please try again.', 'danger');
}
}
function updateLikeButton(isLiked) {
const btn = document.getElementById('likeBtn');
const btnText = document.getElementById('likeBtnText');
const icon = btn.querySelector('i');
if (isLiked) {
btn.classList.remove('btn-outline-danger');
btn.classList.add('btn-danger');
icon.classList.remove('bi-heart');
icon.classList.add('bi-heart-fill');
btnText.textContent = 'Liked';
} else {
btn.classList.remove('btn-danger');
btn.classList.add('btn-outline-danger');
icon.classList.remove('bi-heart-fill');
icon.classList.add('bi-heart');
btnText.textContent = 'Like';
}
}
async function handleCommentSubmit(event) {
event.preventDefault();
const contentInput = document.getElementById('commentContent');
const content = contentInput.value.trim();
if (!content) return;
try {
const response = await FitPubAuth.authenticatedFetch(
`/api/activities/${activityId}/comments`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ content })
}
);
if (response.ok) {
contentInput.value = '';
loadComments(); // Reload comments list
FitPub.showAlert('Comment posted successfully', 'success');
} else {
throw new Error('Failed to post comment');
}
} catch (error) {
console.error('Error posting comment:', error);
FitPub.showAlert('Failed to post comment. Please try again.', 'danger');
}
}
async function deleteComment(commentId) {
try {
const response = await FitPubAuth.authenticatedFetch(
`/api/activities/${activityId}/comments/${commentId}`,
{ method: 'DELETE' }
);
if (response.ok) {
loadComments(); // Reload comments list
FitPub.showAlert('Comment deleted successfully', 'success');
} else {
throw new Error('Failed to delete comment');
}
} catch (error) {
console.error('Error deleting comment:', error);
FitPub.showAlert('Failed to delete comment. Please try again.', 'danger');
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Delete functionality
document.getElementById('deleteBtn').addEventListener('click', function() {
const modal = new bootstrap.Modal(document.getElementById('deleteModal'));
modal.show();
});
document.getElementById('confirmDeleteBtn').addEventListener('click', async function() {
try {
const response = await FitPubAuth.authenticatedFetch(
`/api/activities/${activityId}`,
{ method: 'DELETE' }
);
if (response.ok) {
// Close modal and redirect
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteModal'));
modal.hide();
FitPub.showAlert('Activity deleted successfully', 'success');
setTimeout(() => {
window.location.href = '/activities';
}, 1000);
} else {
throw new Error('Failed to delete activity');
}
} catch (error) {
console.error('Delete error:', error);
FitPub.showAlert('Failed to delete activity. Please try again.', 'danger');
}
});
// Helper functions
function formatDistance(meters) {
if (!meters) return 'N/A';
if (meters >= 1000) {
return (meters / 1000).toFixed(2) + ' km';
}
return Math.round(meters) + ' m';
}
function formatDuration(seconds) {
if (!seconds) return 'N/A';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
const parts = [];
if (hours > 0) parts.push(hours + 'h');
if (minutes > 0) parts.push(minutes + 'm');
if (secs > 0 || parts.length === 0) parts.push(secs + 's');
return parts.join(' ');
}
function formatPace(secondsPerKm) {
if (!secondsPerKm) return 'N/A';
const minutes = Math.floor(secondsPerKm / 60);
const seconds = Math.floor(secondsPerKm % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}/km`;
}
function getVisibilityIcon(visibility) {
switch (visibility) {
case 'PUBLIC': return 'globe';
case 'FOLLOWERS': return 'people';
case 'PRIVATE': return 'lock';
default: return 'question-circle';
}
}
/**
* Show indoor activity placeholder with appropriate emoji
*/
function showIndoorPlaceholder(activityType) {
const emojiMap = {
'RUN': '🏃',
'RIDE': '🚴',
'CYCLING': '🚴',
'INDOOR_CYCLING': '🚴',
'HIKE': '🥾',
'WALK': '🚶',
'SWIM': '🏊',
'WORKOUT': '💪',
'YOGA': '🧘',
'ALPINE_SKI': '⛷️',
'NORDIC_SKI': '⛷️',
'SNOWBOARD': '🏂',
'ROWING': '🚣',
'KAYAKING': '🛶',
'CANOEING': '🛶',
'ROCK_CLIMBING': '🧗',
'MOUNTAINEERING': '⛰️',
'OTHER': '🏋️'
};
const nameMap = {
'RUN': 'Indoor Running',
'RIDE': 'Indoor Cycling',
'CYCLING': 'Indoor Cycling',
'INDOOR_CYCLING': 'Indoor Cycling',
'HIKE': 'Indoor Activity',
'WALK': 'Indoor Walking',
'SWIM': 'Indoor Swimming',
'WORKOUT': 'Workout',
'YOGA': 'Yoga',
'ALPINE_SKI': 'Skiing',
'NORDIC_SKI': 'Cross-Country Skiing',
'SNOWBOARD': 'Snowboarding',
'ROWING': 'Indoor Rowing',
'KAYAKING': 'Kayaking',
'CANOEING': 'Canoeing',
'ROCK_CLIMBING': 'Climbing',
'MOUNTAINEERING': 'Mountaineering',
'OTHER': 'Indoor Activity'
};
const emoji = emojiMap[activityType] || '🏋️';
const name = nameMap[activityType] || 'Indoor Activity';
document.getElementById('activityTypeEmoji').textContent = emoji;
document.getElementById('activityTypeName').textContent = name;
}
});
</script>
</th:block>
</body>
</html>