This commit is contained in:
Tim Zöller 2025-11-29 09:56:55 +01:00
parent c1729a629d
commit ac53f04e0a
27 changed files with 3019 additions and 88 deletions

View file

@ -0,0 +1,439 @@
/**
* 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`;
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="row text-center mb-3">
<div class="col-6 col-md-3">
<div class="metric-card">
<div class="metric-value">${this.formatDistance(activity.totalDistance)}</div>
<div class="metric-label">Distance</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="metric-card">
<div class="metric-value">${this.formatDuration(activity.totalDurationSeconds)}</div>
<div class="metric-label">Duration</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="metric-card">
<div class="metric-value">${this.formatPace(activity.totalDurationSeconds, activity.totalDistance)}</div>
<div class="metric-label">Avg Pace</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="metric-card">
<div class="metric-value">${activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A'}</div>
<div class="metric-label">Elevation</div>
</div>
</div>
</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">
<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">
<i class="bi bi-${this.getVisibilityIcon(activity.visibility)}"></i>
${activity.visibility}
</span>
</div>
</div>
</div>
`;
}).join('');
// Render maps after DOM is updated
setTimeout(() => {
activities.forEach(activity => {
this.renderPreviewMap(activity);
});
}, 100);
},
/**
* 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;
}
};