MVP done
This commit is contained in:
parent
c1729a629d
commit
ac53f04e0a
27 changed files with 3019 additions and 88 deletions
439
src/main/resources/static/js/timeline.js
Normal file
439
src/main/resources/static/js/timeline.js
Normal 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;
|
||||
}
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue