/** * Timeline functionality for FitPub * Handles loading and displaying timeline activities with preview maps */ const FitPubTimeline = { currentPage: 0, totalPages: 0, timelineType: 'public', /** * Initialize the timeline * @param {string} type - Timeline type: 'public', 'federated', or 'user' */ init: function(type) { this.timelineType = type; this.loadTimeline(0); }, /** * Load timeline activities * @param {number} page - Page number to load */ loadTimeline: async function(page) { const loadingIndicator = document.getElementById('loadingIndicator'); const errorAlert = document.getElementById('errorAlert'); const errorMessage = document.getElementById('errorMessage'); const timelineList = document.getElementById('timelineList'); const emptyState = document.getElementById('emptyState'); const pagination = document.getElementById('pagination'); try { // Show loading loadingIndicator.classList.remove('d-none'); timelineList.classList.add('d-none'); emptyState.classList.add('d-none'); errorAlert.classList.add('d-none'); pagination.classList.add('d-none'); // Determine endpoint let endpoint; let fetchOptions = {}; switch (this.timelineType) { case 'public': endpoint = `/api/timeline/public?page=${page}&size=20`; // Public timeline is optionally authenticated fetchOptions = { useAuth: FitPubAuth.isAuthenticated() }; break; case 'federated': endpoint = `/api/timeline/federated?page=${page}&size=20`; fetchOptions = { useAuth: true }; break; case 'user': endpoint = `/api/timeline/user?page=${page}&size=20`; fetchOptions = { useAuth: true }; break; default: throw new Error('Invalid timeline type'); } // Fetch timeline data const response = fetchOptions.useAuth ? await FitPubAuth.authenticatedFetch(endpoint) : await fetch(endpoint); if (response.ok) { const data = await response.json(); // Hide loading loadingIndicator.classList.add('d-none'); if (data.content && data.content.length > 0) { this.renderTimeline(data.content); this.renderPagination(data); timelineList.classList.remove('d-none'); pagination.classList.remove('d-none'); } else { emptyState.classList.remove('d-none'); } this.totalPages = data.totalPages; this.currentPage = data.number; } else { throw new Error('Failed to load timeline'); } } catch (error) { console.error('Error loading timeline:', error); loadingIndicator.classList.add('d-none'); errorMessage.textContent = 'Failed to load timeline. Please try again.'; errorAlert.classList.remove('d-none'); } }, /** * Render timeline activities * @param {Array} activities - Array of timeline activity objects */ renderTimeline: function(activities) { const timelineList = document.getElementById('timelineList'); timelineList.innerHTML = activities.map((activity, index) => { const mapId = `map-${activity.id}`; return `
${activity.avatarUrl ? `${this.escapeHtml(activity.displayName || activity.username)}` : `
` }
${this.escapeHtml(activity.displayName || activity.username)}
@${this.escapeHtml(activity.username)} ${!activity.isLocal ? ' ' : ''} • ${this.formatTimeAgo(activity.startedAt)}
${activity.activityType}
${this.escapeHtml(activity.title || 'Untitled Activity')}
${activity.description ? `

${this.escapeHtml(activity.description).substring(0, 200)}${activity.description.length > 200 ? '...' : ''}

` : '' }
Distance: ${this.formatDistance(activity.totalDistance)} • Duration: ${this.formatDuration(activity.totalDurationSeconds)} • Pace: ${this.formatPace(activity.totalDurationSeconds, activity.totalDistance)} • Elevation: ${activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A'}
View Details ${activity.commentsCount > 0 ? ` ${activity.commentsCount}` : ''} ${activity.visibility}
`; }).join(''); // Render maps after DOM is updated setTimeout(() => { activities.forEach(activity => { this.renderPreviewMap(activity); }); }, 100); // Setup like button handlers this.setupLikeButtons(); }, /** * Setup like button click handlers */ setupLikeButtons: function() { const likeButtons = document.querySelectorAll('.like-btn'); likeButtons.forEach(btn => { btn.addEventListener('click', async (e) => { e.preventDefault(); // Check if user is authenticated if (!FitPubAuth.isAuthenticated()) { window.location.href = '/login'; return; } const activityId = btn.dataset.activityId; const isLiked = btn.dataset.liked === 'true'; const icon = btn.querySelector('i'); const countSpan = btn.querySelector('.like-count'); try { // Disable button during request btn.disabled = true; if (isLiked) { // Unlike const response = await FitPubAuth.authenticatedFetch( `/api/activities/${activityId}/likes`, { method: 'DELETE' } ); if (response.ok) { // Update UI btn.classList.remove('btn-danger'); btn.classList.add('btn-outline-danger'); icon.classList.remove('bi-heart-fill'); icon.classList.add('bi-heart'); btn.dataset.liked = 'false'; // Update count const currentCount = parseInt(countSpan.textContent) || 0; countSpan.textContent = Math.max(0, currentCount - 1); } } else { // Like const response = await FitPubAuth.authenticatedFetch( `/api/activities/${activityId}/likes`, { method: 'POST' } ); if (response.ok) { // Update UI btn.classList.remove('btn-outline-danger'); btn.classList.add('btn-danger'); icon.classList.remove('bi-heart'); icon.classList.add('bi-heart-fill'); btn.dataset.liked = 'true'; // Update count const currentCount = parseInt(countSpan.textContent) || 0; countSpan.textContent = currentCount + 1; } } } catch (error) { console.error('Error toggling like:', error); } finally { btn.disabled = false; } }); }); }, /** * Render preview map for an activity * @param {Object} activity - Activity object */ renderPreviewMap: async function(activity) { const mapId = `map-${activity.id}`; const mapElement = document.getElementById(mapId); if (!mapElement) { console.warn('Map element not found:', mapId); return; } try { // Fetch track data const response = await fetch(`/api/activities/${activity.id}/track`); if (!response.ok) { throw new Error('Failed to load track data'); } const trackData = await response.json(); if (!trackData.features || trackData.features.length === 0) { mapElement.innerHTML = '

No GPS data available

'; return; } // Initialize map const map = L.map(mapId, { zoomControl: true, scrollWheelZoom: false, dragging: true, touchZoom: true }); // Add tile layer L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { attribution: '© OpenStreetMap contributors', maxZoom: 18 }).addTo(map); // Add track to map const geoJsonLayer = L.geoJSON(trackData, { style: { color: '#0d6efd', weight: 3, opacity: 0.8 } }).addTo(map); // Fit map to track bounds map.fitBounds(geoJsonLayer.getBounds(), { padding: [20, 20] }); // Add start/finish markers const coordinates = trackData.features[0].geometry.coordinates; if (coordinates.length > 0) { // Start marker (green) const startCoord = coordinates[0]; L.circleMarker([startCoord[1], startCoord[0]], { radius: 6, fillColor: '#28a745', color: '#fff', weight: 2, opacity: 1, fillOpacity: 1 }).addTo(map); // Finish marker (red) const endCoord = coordinates[coordinates.length - 1]; L.circleMarker([endCoord[1], endCoord[0]], { radius: 6, fillColor: '#dc3545', color: '#fff', weight: 2, opacity: 1, fillOpacity: 1 }).addTo(map); } } catch (error) { console.error('Error rendering map:', error); mapElement.innerHTML = '

Failed to load map

'; } }, /** * Render pagination controls * @param {Object} data - Pagination data from API */ renderPagination: function(data) { const paginationList = document.getElementById('paginationList'); let html = ''; // Previous button html += `
  • `; // Page numbers const startPage = Math.max(0, data.number - 2); const endPage = Math.min(data.totalPages - 1, data.number + 2); if (startPage > 0) { html += `
  • ...
  • `; } for (let i = startPage; i <= endPage; i++) { html += `
  • ${i + 1}
  • `; } if (endPage < data.totalPages - 1) { html += `
  • ...
  • `; } // Next button html += `
  • `; paginationList.innerHTML = html; }, /** * Change page * @param {number} page - Page number */ changePage: function(page) { this.loadTimeline(page); window.scrollTo({ top: 0, behavior: 'smooth' }); }, /** * Format distance in meters to km * @param {number} meters - Distance in meters * @returns {string} Formatted distance */ formatDistance: function(meters) { if (!meters) return 'N/A'; if (meters >= 1000) { return (meters / 1000).toFixed(1) + ' km'; } return Math.round(meters) + ' m'; }, /** * Format duration in seconds * @param {number} seconds - Duration in seconds * @returns {string} Formatted duration */ formatDuration: function(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); if (hours > 0) { return `${hours}h ${minutes}m`; } if (minutes > 0) { return `${minutes}m ${secs}s`; } return `${secs}s`; }, /** * Format pace (min/km) * @param {number} seconds - Total duration in seconds * @param {number} meters - Total distance in meters * @returns {string} Formatted pace */ formatPace: function(seconds, meters) { if (!seconds || !meters || meters === 0) return 'N/A'; const km = meters / 1000; const paceSeconds = seconds / km; const paceMinutes = Math.floor(paceSeconds / 60); const paceSecs = Math.floor(paceSeconds % 60); return `${paceMinutes}:${paceSecs.toString().padStart(2, '0')}/km`; }, /** * Format timestamp to "time ago" format * @param {string} timestamp - ISO timestamp * @returns {string} Time ago string */ formatTimeAgo: function(timestamp) { const date = new Date(timestamp); const now = new Date(); const secondsAgo = Math.floor((now - date) / 1000); if (secondsAgo < 60) return 'just now'; if (secondsAgo < 3600) return `${Math.floor(secondsAgo / 60)}m ago`; if (secondsAgo < 86400) return `${Math.floor(secondsAgo / 3600)}h ago`; if (secondsAgo < 604800) return `${Math.floor(secondsAgo / 86400)}d ago`; return date.toLocaleDateString(); }, /** * Get visibility icon * @param {string} visibility - Visibility level * @returns {string} Bootstrap icon name */ getVisibilityIcon: function(visibility) { switch (visibility) { case 'PUBLIC': return 'globe'; case 'FOLLOWERS': return 'people'; case 'PRIVATE': return 'lock'; default: return 'question-circle'; } }, /** * Escape HTML to prevent XSS * @param {string} text - Text to escape * @returns {string} Escaped text */ escapeHtml: function(text) { const div = document.createElement('div'); div.textContent = text; return div.innerHTML; } };