510 lines
20 KiB
JavaScript
510 lines
20 KiB
JavaScript
/**
|
|
* 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 `
|
|
<div class="timeline-card card mb-4">
|
|
<div class="card-body">
|
|
<!-- User Info -->
|
|
<div class="d-flex align-items-center mb-3">
|
|
<a href="/users/${activity.username}" class="user-avatar me-3 text-decoration-none">
|
|
${activity.avatarUrl
|
|
? `<img src="${activity.avatarUrl}" alt="${this.escapeHtml(activity.displayName || activity.username)}" class="rounded-circle" width="48" height="48">`
|
|
: `<div class="avatar-placeholder rounded-circle">
|
|
<i class="bi bi-person-circle"></i>
|
|
</div>`
|
|
}
|
|
</a>
|
|
<div class="flex-grow-1">
|
|
<a href="/users/${activity.username}" class="text-decoration-none text-dark">
|
|
<div class="fw-bold">${this.escapeHtml(activity.displayName || activity.username)}</div>
|
|
</a>
|
|
<div class="text-muted small">
|
|
<a href="/users/${activity.username}" class="text-decoration-none text-muted">
|
|
@${this.escapeHtml(activity.username)}
|
|
</a>
|
|
${!activity.isLocal ? ' <i class="bi bi-globe2" title="Federated user"></i>' : ''}
|
|
• ${this.formatTimeAgo(activity.startedAt)}
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<span class="activity-type-badge activity-type-${activity.activityType.toLowerCase()}">
|
|
${activity.activityType}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Activity Title and Description -->
|
|
<h5 class="card-title">
|
|
<a href="/activities/${activity.id}" class="text-decoration-none text-dark">
|
|
${this.escapeHtml(activity.title || 'Untitled Activity')}
|
|
</a>
|
|
</h5>
|
|
|
|
${activity.description
|
|
? `<p class="card-text">${this.escapeHtml(activity.description).substring(0, 200)}${activity.description.length > 200 ? '...' : ''}</p>`
|
|
: ''
|
|
}
|
|
|
|
<!-- Activity Metrics -->
|
|
<div class="mb-2">
|
|
<small class="text-muted">
|
|
<strong>Distance:</strong> ${this.formatDistance(activity.totalDistance)} •
|
|
<strong>Duration:</strong> ${this.formatDuration(activity.totalDurationSeconds)} •
|
|
<strong>Pace:</strong> ${this.formatPace(activity.totalDurationSeconds, activity.totalDistance)} •
|
|
<strong>Elevation:</strong> ${activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A'}
|
|
</small>
|
|
</div>
|
|
|
|
<!-- Preview Map -->
|
|
<div class="activity-preview-map" id="${mapId}" style="height: 300px; border-radius: 8px; margin-bottom: 1rem;">
|
|
<!-- Map will be rendered here -->
|
|
</div>
|
|
|
|
<!-- Activity Actions -->
|
|
<div class="d-flex gap-2 align-items-center">
|
|
<button
|
|
class="btn btn-sm ${activity.likedByCurrentUser ? 'btn-danger' : 'btn-outline-danger'} like-btn"
|
|
data-activity-id="${activity.id}"
|
|
data-liked="${activity.likedByCurrentUser || false}"
|
|
>
|
|
<i class="bi bi-heart${activity.likedByCurrentUser ? '-fill' : ''}"></i>
|
|
<span class="like-count">${activity.likesCount || 0}</span>
|
|
</button>
|
|
<a href="/activities/${activity.id}" class="btn btn-sm btn-outline-primary">
|
|
<i class="bi bi-eye"></i> View Details
|
|
</a>
|
|
<span class="ms-auto text-muted small d-flex align-items-center gap-2">
|
|
${activity.commentsCount > 0 ? `<span><i class="bi bi-chat-left-text"></i> ${activity.commentsCount}</span>` : ''}
|
|
<span>
|
|
<i class="bi bi-${this.getVisibilityIcon(activity.visibility)}"></i>
|
|
${activity.visibility}
|
|
</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
`;
|
|
}).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 = '<div class="d-flex align-items-center justify-content-center h-100 bg-light"><p class="text-muted">No GPS data available</p></div>';
|
|
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 = '<div class="d-flex align-items-center justify-content-center h-100 bg-light"><p class="text-muted">Failed to load map</p></div>';
|
|
}
|
|
},
|
|
|
|
/**
|
|
* Render pagination controls
|
|
* @param {Object} data - Pagination data from API
|
|
*/
|
|
renderPagination: function(data) {
|
|
const paginationList = document.getElementById('paginationList');
|
|
let html = '';
|
|
|
|
// Previous button
|
|
html += `
|
|
<li class="page-item ${data.first ? 'disabled' : ''}">
|
|
<a class="page-link" href="#" onclick="FitPubTimeline.changePage(${data.number - 1}); return false;">
|
|
<i class="bi bi-chevron-left"></i>
|
|
</a>
|
|
</li>
|
|
`;
|
|
|
|
// Page numbers
|
|
const startPage = Math.max(0, data.number - 2);
|
|
const endPage = Math.min(data.totalPages - 1, data.number + 2);
|
|
|
|
if (startPage > 0) {
|
|
html += `<li class="page-item disabled"><span class="page-link">...</span></li>`;
|
|
}
|
|
|
|
for (let i = startPage; i <= endPage; i++) {
|
|
html += `
|
|
<li class="page-item ${i === data.number ? 'active' : ''}">
|
|
<a class="page-link" href="#" onclick="FitPubTimeline.changePage(${i}); return false;">${i + 1}</a>
|
|
</li>
|
|
`;
|
|
}
|
|
|
|
if (endPage < data.totalPages - 1) {
|
|
html += `<li class="page-item disabled"><span class="page-link">...</span></li>`;
|
|
}
|
|
|
|
// Next button
|
|
html += `
|
|
<li class="page-item ${data.last ? 'disabled' : ''}">
|
|
<a class="page-link" href="#" onclick="FitPubTimeline.changePage(${data.number + 1}); return false;">
|
|
<i class="bi bi-chevron-right"></i>
|
|
</a>
|
|
</li>
|
|
`;
|
|
|
|
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;
|
|
}
|
|
};
|