MVP done
This commit is contained in:
parent
c1729a629d
commit
ac53f04e0a
27 changed files with 3019 additions and 88 deletions
|
|
@ -7,13 +7,18 @@ spring:
|
|||
|
||||
jpa:
|
||||
hibernate:
|
||||
ddl-auto: update # Auto-update schema in dev mode
|
||||
ddl-auto: validate # Use Flyway for schema management, even in dev
|
||||
show-sql: true # Show SQL queries in console
|
||||
properties:
|
||||
hibernate:
|
||||
format_sql: true # Format SQL for readability
|
||||
use_sql_comments: true # Add comments to SQL
|
||||
|
||||
flyway:
|
||||
enabled: true # Use Flyway for migrations
|
||||
baseline-on-migrate: true
|
||||
locations: classpath:db/migration
|
||||
|
||||
# Development-specific FitPub configuration
|
||||
fitpub:
|
||||
domain: ${FITPUB_DOMAIN:localhost:8080}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,10 @@ spring:
|
|||
application:
|
||||
name: fitpub
|
||||
|
||||
# Default to dev profile if not specified
|
||||
profiles:
|
||||
active: ${SPRING_PROFILES_ACTIVE:dev}
|
||||
|
||||
# Datasource configuration is handled by Testcontainers in dev mode
|
||||
# For production, set these via environment variables:
|
||||
# - SPRING_DATASOURCE_URL
|
||||
|
|
|
|||
|
|
@ -135,6 +135,94 @@ body {
|
|||
font-weight: 600;
|
||||
}
|
||||
|
||||
/* Timeline Cards */
|
||||
.timeline-card {
|
||||
transition: transform 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.timeline-card:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.timeline-card .user-avatar {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.timeline-card .avatar-placeholder {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
background-color: var(--light-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 2rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.activity-preview-map {
|
||||
background-color: var(--light-color);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.activity-preview-map .leaflet-container {
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
/* Timeline Metrics */
|
||||
.timeline-card .metric-card {
|
||||
padding: 0.75rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.timeline-card .metric-value {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.timeline-card .metric-label {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
/* Profile Pages */
|
||||
.avatar-placeholder-large {
|
||||
width: 120px;
|
||||
height: 120px;
|
||||
background-color: var(--light-color);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 4rem;
|
||||
color: #9ca3af;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 2rem;
|
||||
font-weight: 700;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 0.875rem;
|
||||
color: #6b7280;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.activity-item {
|
||||
transition: background-color 0.2s;
|
||||
}
|
||||
|
||||
.activity-item:hover {
|
||||
background-color: var(--light-color);
|
||||
border-radius: var(--border-radius);
|
||||
padding: 0.5rem;
|
||||
margin: -0.5rem;
|
||||
}
|
||||
|
||||
/* Charts */
|
||||
.chart-container {
|
||||
position: relative;
|
||||
|
|
|
|||
|
|
@ -222,8 +222,12 @@ function createActivityMap(containerId, geoJsonData, options = {}) {
|
|||
if (mapOptions.fitBounds) {
|
||||
try {
|
||||
const bounds = trackLayer.getBounds();
|
||||
console.log('Track bounds:', bounds);
|
||||
if (bounds.isValid()) {
|
||||
map.fitBounds(bounds, { padding: [50, 50] });
|
||||
console.log('Map bounds fitted successfully');
|
||||
} else {
|
||||
console.warn('Track bounds are invalid');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not fit map bounds:', e);
|
||||
|
|
@ -243,9 +247,22 @@ function createActivityMap(containerId, geoJsonData, options = {}) {
|
|||
map.setView([0, 0], 2);
|
||||
}
|
||||
|
||||
// Invalidate size to ensure proper rendering
|
||||
// Invalidate size to ensure proper rendering and re-fit bounds
|
||||
setTimeout(() => {
|
||||
map.invalidateSize();
|
||||
|
||||
// Re-fit bounds after size invalidation if we have a track
|
||||
if (mapOptions.fitBounds && map.trackLayer) {
|
||||
try {
|
||||
const bounds = map.trackLayer.getBounds();
|
||||
if (bounds.isValid()) {
|
||||
map.fitBounds(bounds, { padding: [50, 50] });
|
||||
console.log('Map bounds re-fitted after invalidateSize');
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not re-fit bounds after invalidateSize:', e);
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
|
||||
return map;
|
||||
|
|
|
|||
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;
|
||||
}
|
||||
};
|
||||
|
|
@ -296,26 +296,75 @@
|
|||
coordinates: simplifiedTrack.coordinates
|
||||
};
|
||||
|
||||
// Create map
|
||||
FitPub.createActivityMap('activityMap', geoJson, {
|
||||
showStartEnd: true,
|
||||
fitBounds: true
|
||||
});
|
||||
// Create map (needs to be done after container is visible)
|
||||
setTimeout(() => {
|
||||
const map = FitPub.createActivityMap('activityMap', geoJson, {
|
||||
showStartEnd: true,
|
||||
fitBounds: true
|
||||
});
|
||||
|
||||
// Force fit bounds again after map is fully rendered
|
||||
if (map && map.trackLayer) {
|
||||
setTimeout(() => {
|
||||
try {
|
||||
const bounds = map.trackLayer.getBounds();
|
||||
if (bounds.isValid()) {
|
||||
map.fitBounds(bounds, { padding: [50, 50] });
|
||||
}
|
||||
} catch (e) {
|
||||
console.warn('Could not fit bounds on second attempt:', e);
|
||||
}
|
||||
}, 200);
|
||||
}
|
||||
}, 50);
|
||||
}
|
||||
|
||||
function renderElevationChart(trackPoints) {
|
||||
const elevationData = trackPoints
|
||||
.filter(p => p.elevation != null)
|
||||
.map((p, index) => ({
|
||||
x: index,
|
||||
y: p.elevation
|
||||
}));
|
||||
// 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
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (elevationData.length > 0) {
|
||||
FitPub.createElevationChart('elevationChart', elevationData);
|
||||
}
|
||||
}
|
||||
|
||||
// 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;
|
||||
|
||||
|
|
|
|||
|
|
@ -197,8 +197,7 @@
|
|||
crossorigin="anonymous"></script>
|
||||
|
||||
<!-- Chart.js -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"
|
||||
crossorigin="anonymous"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
|
||||
|
||||
<!-- Custom JS -->
|
||||
<script th:src="@{/js/auth.js}"></script>
|
||||
|
|
|
|||
243
src/main/resources/templates/profile/edit.html
Normal file
243
src/main/resources/templates/profile/edit.html
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
<!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>Edit Profile</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-pencil"></i> Edit Profile
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- 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 profile...</p>
|
||||
</div>
|
||||
|
||||
<!-- Edit Form -->
|
||||
<form id="editProfileForm" class="d-none">
|
||||
<!-- Display Name -->
|
||||
<div class="mb-3">
|
||||
<label for="displayName" class="form-label">Display Name</label>
|
||||
<input type="text" class="form-control" id="displayName" name="displayName" maxlength="100">
|
||||
<div class="form-text">Your name as it appears to others</div>
|
||||
</div>
|
||||
|
||||
<!-- Bio -->
|
||||
<div class="mb-3">
|
||||
<label for="bio" class="form-label">Bio</label>
|
||||
<textarea class="form-control" id="bio" name="bio" rows="4" maxlength="500"></textarea>
|
||||
<div class="form-text">
|
||||
<span id="bioCharCount">0</span>/500 characters
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Avatar URL -->
|
||||
<div class="mb-3">
|
||||
<label for="avatarUrl" class="form-label">Avatar URL</label>
|
||||
<input type="url" class="form-control" id="avatarUrl" name="avatarUrl" placeholder="https://example.com/avatar.jpg">
|
||||
<div class="form-text">URL to your profile picture</div>
|
||||
</div>
|
||||
|
||||
<!-- Avatar Preview -->
|
||||
<div class="mb-3" id="avatarPreviewContainer" style="display: none;">
|
||||
<label class="form-label">Avatar Preview</label>
|
||||
<div>
|
||||
<img id="avatarPreview" src="" alt="Avatar preview" class="rounded-circle" width="100" height="100">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email (read-only for now) -->
|
||||
<div class="mb-3">
|
||||
<label for="email" class="form-label">Email</label>
|
||||
<input type="email" class="form-control" id="email" name="email" readonly>
|
||||
<div class="form-text">Email cannot be changed here</div>
|
||||
</div>
|
||||
|
||||
<!-- Username (read-only) -->
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">Username</label>
|
||||
<input type="text" class="form-control" id="username" name="username" readonly>
|
||||
<div class="form-text">Username cannot be changed</div>
|
||||
</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>
|
||||
|
||||
<!-- Success Alert -->
|
||||
<div id="successAlert" class="alert alert-success d-none" role="alert">
|
||||
<i class="bi bi-check-circle-fill"></i>
|
||||
Profile updated successfully!
|
||||
</div>
|
||||
|
||||
<!-- Action Buttons -->
|
||||
<div class="d-flex justify-content-between">
|
||||
<a th:href="@{/profile}" class="btn btn-outline-secondary">
|
||||
<i class="bi bi-arrow-left"></i> Cancel
|
||||
</a>
|
||||
<button type="submit" class="btn btn-primary" id="saveBtn">
|
||||
<span id="saveBtnText">
|
||||
<i class="bi bi-save"></i> Save Changes
|
||||
</span>
|
||||
<span id="saveBtnLoading" class="d-none">
|
||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||
Saving...
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Scripts -->
|
||||
<th:block layout:fragment="scripts">
|
||||
<script th:inline="javascript">
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Redirect to login if not authenticated
|
||||
if (!FitPubAuth.isAuthenticated()) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
const form = document.getElementById('editProfileForm');
|
||||
const bioInput = document.getElementById('bio');
|
||||
const bioCharCount = document.getElementById('bioCharCount');
|
||||
const avatarUrlInput = document.getElementById('avatarUrl');
|
||||
const avatarPreview = document.getElementById('avatarPreview');
|
||||
const avatarPreviewContainer = document.getElementById('avatarPreviewContainer');
|
||||
|
||||
loadProfile();
|
||||
|
||||
// Bio character counter
|
||||
bioInput.addEventListener('input', function() {
|
||||
bioCharCount.textContent = bioInput.value.length;
|
||||
});
|
||||
|
||||
// Avatar preview
|
||||
avatarUrlInput.addEventListener('input', function() {
|
||||
const url = avatarUrlInput.value.trim();
|
||||
if (url) {
|
||||
avatarPreview.src = url;
|
||||
avatarPreviewContainer.style.display = 'block';
|
||||
} else {
|
||||
avatarPreviewContainer.style.display = 'none';
|
||||
}
|
||||
});
|
||||
|
||||
// Form submission
|
||||
form.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
// Hide alerts
|
||||
document.getElementById('errorAlert').classList.add('d-none');
|
||||
document.getElementById('successAlert').classList.add('d-none');
|
||||
|
||||
// Show loading state
|
||||
document.getElementById('saveBtnText').classList.add('d-none');
|
||||
document.getElementById('saveBtnLoading').classList.remove('d-none');
|
||||
document.getElementById('saveBtn').disabled = true;
|
||||
|
||||
try {
|
||||
const formData = {
|
||||
displayName: document.getElementById('displayName').value.trim(),
|
||||
bio: document.getElementById('bio').value.trim(),
|
||||
avatarUrl: document.getElementById('avatarUrl').value.trim()
|
||||
};
|
||||
|
||||
const response = await FitPubAuth.authenticatedFetch('/api/users/me', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(formData)
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
// Show success message
|
||||
document.getElementById('successAlert').classList.remove('d-none');
|
||||
|
||||
// Scroll to top
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
|
||||
// Redirect to profile after 2 seconds
|
||||
setTimeout(() => {
|
||||
window.location.href = '/profile';
|
||||
}, 2000);
|
||||
} else {
|
||||
const errorData = await response.json();
|
||||
throw new Error(errorData.message || 'Failed to update profile');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error updating profile:', error);
|
||||
document.getElementById('errorMessage').textContent = error.message;
|
||||
document.getElementById('errorAlert').classList.remove('d-none');
|
||||
|
||||
// Reset button state
|
||||
document.getElementById('saveBtnText').classList.remove('d-none');
|
||||
document.getElementById('saveBtnLoading').classList.add('d-none');
|
||||
document.getElementById('saveBtn').disabled = false;
|
||||
}
|
||||
});
|
||||
|
||||
async function loadProfile() {
|
||||
try {
|
||||
const response = await FitPubAuth.authenticatedFetch('/api/users/me');
|
||||
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
populateForm(user);
|
||||
|
||||
// Show form, hide loading
|
||||
document.getElementById('loadingIndicator').classList.add('d-none');
|
||||
document.getElementById('editProfileForm').classList.remove('d-none');
|
||||
} else {
|
||||
throw new Error('Failed to load profile');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading profile:', error);
|
||||
document.getElementById('loadingIndicator').classList.add('d-none');
|
||||
document.getElementById('errorMessage').textContent = 'Failed to load profile. Please try again.';
|
||||
document.getElementById('errorAlert').classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
function populateForm(user) {
|
||||
document.getElementById('displayName').value = user.displayName || '';
|
||||
document.getElementById('bio').value = user.bio || '';
|
||||
document.getElementById('avatarUrl').value = user.avatarUrl || '';
|
||||
document.getElementById('email').value = user.email || '';
|
||||
document.getElementById('username').value = user.username || '';
|
||||
|
||||
// Update character count
|
||||
bioCharCount.textContent = (user.bio || '').length;
|
||||
|
||||
// Show avatar preview if URL exists
|
||||
if (user.avatarUrl) {
|
||||
avatarPreview.src = user.avatarUrl;
|
||||
avatarPreviewContainer.style.display = 'block';
|
||||
}
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
327
src/main/resources/templates/profile/public.html
Normal file
327
src/main/resources/templates/profile/public.html
Normal file
|
|
@ -0,0 +1,327 @@
|
|||
<!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>User Profile</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 profile...</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>
|
||||
|
||||
<!-- Profile Content -->
|
||||
<div id="profileContent" class="d-none">
|
||||
<!-- Profile Header -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-2 text-center">
|
||||
<!-- Avatar -->
|
||||
<div id="avatarContainer" class="mb-3">
|
||||
<img id="avatarImage" src="" alt="Avatar" class="rounded-circle d-none" width="120" height="120">
|
||||
<div id="avatarPlaceholder" class="avatar-placeholder-large rounded-circle mx-auto">
|
||||
<i class="bi bi-person-circle"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-10">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h2 id="displayName" class="mb-1"></h2>
|
||||
<p class="text-muted mb-2">
|
||||
<span id="username"></span>
|
||||
</p>
|
||||
<p id="bio" class="mb-3"></p>
|
||||
</div>
|
||||
<div id="followButtonContainer" class="d-none">
|
||||
<button class="btn btn-primary" id="followBtn">
|
||||
<i class="bi bi-person-plus"></i> Follow
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="row text-center">
|
||||
<div class="col-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="activitiesCount">0</div>
|
||||
<div class="stat-label">Activities</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="followersCount">0</div>
|
||||
<div class="stat-label">Followers</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="followingCount">0</div>
|
||||
<div class="stat-label">Following</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Info -->
|
||||
<div class="mt-3 text-muted small">
|
||||
<i class="bi bi-calendar"></i> Joined <span id="joinedDate"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Public Activities -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-list-task"></i> Public Activities
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Loading Indicator for Activities -->
|
||||
<div id="activitiesLoading" class="text-center py-3">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activities List -->
|
||||
<div id="activitiesList" class="d-none">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="activitiesEmpty" class="text-center py-4 d-none">
|
||||
<i class="bi bi-inbox" style="font-size: 3rem; color: #d1d5db;"></i>
|
||||
<p class="text-muted mt-2">No public activities yet</p>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<nav id="pagination" aria-label="Activities pagination" class="mt-3 d-none">
|
||||
<ul class="pagination justify-content-center" id="paginationList">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Scripts -->
|
||||
<th:block layout:fragment="scripts">
|
||||
<script th:inline="javascript">
|
||||
const targetUsername = /*[[${username}]]*/ '';
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadProfile();
|
||||
|
||||
function loadProfile() {
|
||||
// For now, we'll fetch from the user API endpoint
|
||||
// In the future, this should use /api/users/{username}
|
||||
fetch(`/api/users/${targetUsername}`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('User not found');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(user => {
|
||||
renderProfile(user);
|
||||
loadPublicActivities(user.id);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading profile:', error);
|
||||
document.getElementById('loadingIndicator').classList.add('d-none');
|
||||
document.getElementById('errorMessage').textContent = 'User not found or profile could not be loaded.';
|
||||
document.getElementById('errorAlert').classList.remove('d-none');
|
||||
});
|
||||
}
|
||||
|
||||
function renderProfile(user) {
|
||||
// Hide loading, show content
|
||||
document.getElementById('loadingIndicator').classList.add('d-none');
|
||||
document.getElementById('profileContent').classList.remove('d-none');
|
||||
|
||||
// Display name
|
||||
document.getElementById('displayName').textContent = user.displayName || user.username;
|
||||
|
||||
// Username
|
||||
document.getElementById('username').textContent = '@' + user.username;
|
||||
|
||||
// Bio
|
||||
const bioElement = document.getElementById('bio');
|
||||
if (user.bio) {
|
||||
bioElement.textContent = user.bio;
|
||||
} else {
|
||||
bioElement.innerHTML = '<span class="text-muted">No bio</span>';
|
||||
}
|
||||
|
||||
// Avatar
|
||||
if (user.avatarUrl) {
|
||||
document.getElementById('avatarImage').src = user.avatarUrl;
|
||||
document.getElementById('avatarImage').classList.remove('d-none');
|
||||
document.getElementById('avatarPlaceholder').classList.add('d-none');
|
||||
}
|
||||
|
||||
// Joined date
|
||||
const joinedDate = new Date(user.createdAt);
|
||||
document.getElementById('joinedDate').textContent = joinedDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||
|
||||
// Show follow button if viewing another user's profile
|
||||
// TODO: implement follow functionality
|
||||
}
|
||||
|
||||
let currentPage = 0;
|
||||
|
||||
async function loadPublicActivities(userId) {
|
||||
try {
|
||||
const response = await fetch(`/api/activities/user/${targetUsername}?page=${currentPage}&size=10`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('activitiesLoading').classList.add('d-none');
|
||||
|
||||
// Update count
|
||||
document.getElementById('activitiesCount').textContent = data.totalElements || 0;
|
||||
|
||||
if (data.content && data.content.length > 0) {
|
||||
renderActivities(data.content);
|
||||
renderPagination(data);
|
||||
document.getElementById('activitiesList').classList.remove('d-none');
|
||||
|
||||
if (data.totalPages > 1) {
|
||||
document.getElementById('pagination').classList.remove('d-none');
|
||||
}
|
||||
} else {
|
||||
document.getElementById('activitiesEmpty').classList.remove('d-none');
|
||||
}
|
||||
} else {
|
||||
throw new Error('Failed to load activities');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading activities:', error);
|
||||
document.getElementById('activitiesLoading').classList.add('d-none');
|
||||
document.getElementById('activitiesEmpty').classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
function renderActivities(activities) {
|
||||
const list = document.getElementById('activitiesList');
|
||||
list.innerHTML = activities.map(activity => `
|
||||
<div class="activity-item mb-3 pb-3 border-bottom">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1">
|
||||
<a href="/activities/${activity.id}" class="text-decoration-none">
|
||||
${escapeHtml(activity.title || 'Untitled Activity')}
|
||||
</a>
|
||||
</h6>
|
||||
<p class="text-muted small mb-2">
|
||||
<span class="activity-type-badge activity-type-${activity.activityType.toLowerCase()}">
|
||||
${activity.activityType}
|
||||
</span>
|
||||
<span class="ms-2">
|
||||
<i class="bi bi-calendar"></i>
|
||||
${new Date(activity.startedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</p>
|
||||
<div class="d-flex gap-3 text-muted small">
|
||||
<span><i class="bi bi-arrow-left-right"></i> ${formatDistance(activity.totalDistance)}</span>
|
||||
<span><i class="bi bi-clock"></i> ${formatDuration(activity.totalDuration)}</span>
|
||||
${activity.elevationGain ? `<span><i class="bi bi-arrow-up"></i> ${Math.round(activity.elevationGain)}m</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function renderPagination(data) {
|
||||
const paginationList = document.getElementById('paginationList');
|
||||
let html = '';
|
||||
|
||||
// Previous button
|
||||
html += `
|
||||
<li class="page-item ${data.first ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" onclick="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);
|
||||
|
||||
for (let i = startPage; i <= endPage; i++) {
|
||||
html += `
|
||||
<li class="page-item ${i === data.number ? 'active' : ''}">
|
||||
<a class="page-link" href="#" onclick="changePage(${i}); return false;">${i + 1}</a>
|
||||
</li>
|
||||
`;
|
||||
}
|
||||
|
||||
// Next button
|
||||
html += `
|
||||
<li class="page-item ${data.last ? 'disabled' : ''}">
|
||||
<a class="page-link" href="#" onclick="changePage(${data.number + 1}); return false;">
|
||||
<i class="bi bi-chevron-right"></i>
|
||||
</a>
|
||||
</li>
|
||||
`;
|
||||
|
||||
paginationList.innerHTML = html;
|
||||
}
|
||||
|
||||
window.changePage = function(page) {
|
||||
currentPage = page;
|
||||
loadPublicActivities();
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
function formatDistance(meters) {
|
||||
if (!meters) return 'N/A';
|
||||
if (meters >= 1000) {
|
||||
return (meters / 1000).toFixed(1) + ' 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);
|
||||
|
||||
if (hours > 0) {
|
||||
return hours + 'h ' + minutes + 'm';
|
||||
}
|
||||
return minutes + 'm';
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
290
src/main/resources/templates/profile/view.html
Normal file
290
src/main/resources/templates/profile/view.html
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
<!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>My Profile</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 profile...</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>
|
||||
|
||||
<!-- Profile Content -->
|
||||
<div id="profileContent" class="d-none">
|
||||
<!-- Profile Header -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-2 text-center">
|
||||
<!-- Avatar -->
|
||||
<div id="avatarContainer" class="mb-3">
|
||||
<img id="avatarImage" src="" alt="Avatar" class="rounded-circle d-none" width="120" height="120">
|
||||
<div id="avatarPlaceholder" class="avatar-placeholder-large rounded-circle mx-auto">
|
||||
<i class="bi bi-person-circle"></i>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-10">
|
||||
<div class="d-flex justify-content-between align-items-start mb-3">
|
||||
<div>
|
||||
<h2 id="displayName" class="mb-1"></h2>
|
||||
<p class="text-muted mb-2">
|
||||
<span id="username"></span>
|
||||
</p>
|
||||
<p id="bio" class="mb-3"></p>
|
||||
</div>
|
||||
<div>
|
||||
<a th:href="@{/profile/edit}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-pencil"></i> Edit Profile
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stats -->
|
||||
<div class="row text-center">
|
||||
<div class="col-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="activitiesCount">0</div>
|
||||
<div class="stat-label">Activities</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="followersCount">0</div>
|
||||
<div class="stat-label">Followers</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<div class="stat-card">
|
||||
<div class="stat-value" id="followingCount">0</div>
|
||||
<div class="stat-label">Following</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Additional Info -->
|
||||
<div class="mt-3 text-muted small">
|
||||
<i class="bi bi-envelope"></i> <span id="email"></span>
|
||||
<span class="ms-3">
|
||||
<i class="bi bi-calendar"></i> Joined <span id="joinedDate"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Recent Activities -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="bi bi-list-task"></i> Recent Activities
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<!-- Loading Indicator for Activities -->
|
||||
<div id="activitiesLoading" class="text-center py-3">
|
||||
<div class="spinner-border spinner-border-sm text-primary" role="status">
|
||||
<span class="visually-hidden">Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Activities List -->
|
||||
<div id="activitiesList" class="d-none">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="activitiesEmpty" class="text-center py-4 d-none">
|
||||
<i class="bi bi-inbox" style="font-size: 3rem; color: #d1d5db;"></i>
|
||||
<p class="text-muted mt-2">No activities yet</p>
|
||||
<a th:href="@{/activities/upload}" class="btn btn-sm btn-primary">
|
||||
<i class="bi bi-cloud-upload"></i> Upload Activity
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- View All Link -->
|
||||
<div id="viewAllActivities" class="text-center mt-3 d-none">
|
||||
<a th:href="@{/activities}" class="btn btn-sm btn-outline-primary">
|
||||
View All Activities
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Scripts -->
|
||||
<th:block layout:fragment="scripts">
|
||||
<script th:inline="javascript">
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Redirect to login if not authenticated
|
||||
if (!FitPubAuth.isAuthenticated()) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
loadProfile();
|
||||
|
||||
async function loadProfile() {
|
||||
try {
|
||||
// Fetch user profile
|
||||
const response = await FitPubAuth.authenticatedFetch('/api/users/me');
|
||||
|
||||
if (response.ok) {
|
||||
const user = await response.json();
|
||||
renderProfile(user);
|
||||
loadRecentActivities();
|
||||
} else {
|
||||
throw new Error('Failed to load profile');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading profile:', error);
|
||||
document.getElementById('loadingIndicator').classList.add('d-none');
|
||||
document.getElementById('errorMessage').textContent = 'Failed to load profile. Please try again.';
|
||||
document.getElementById('errorAlert').classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
function renderProfile(user) {
|
||||
// Hide loading, show content
|
||||
document.getElementById('loadingIndicator').classList.add('d-none');
|
||||
document.getElementById('profileContent').classList.remove('d-none');
|
||||
|
||||
// Display name
|
||||
document.getElementById('displayName').textContent = user.displayName || user.username;
|
||||
|
||||
// Username
|
||||
document.getElementById('username').textContent = '@' + user.username;
|
||||
|
||||
// Bio
|
||||
const bioElement = document.getElementById('bio');
|
||||
if (user.bio) {
|
||||
bioElement.textContent = user.bio;
|
||||
} else {
|
||||
bioElement.innerHTML = '<span class="text-muted">No bio yet. <a href="/profile/edit">Add one?</a></span>';
|
||||
}
|
||||
|
||||
// Avatar
|
||||
if (user.avatarUrl) {
|
||||
document.getElementById('avatarImage').src = user.avatarUrl;
|
||||
document.getElementById('avatarImage').classList.remove('d-none');
|
||||
document.getElementById('avatarPlaceholder').classList.add('d-none');
|
||||
}
|
||||
|
||||
// Email
|
||||
document.getElementById('email').textContent = user.email;
|
||||
|
||||
// Joined date
|
||||
const joinedDate = new Date(user.createdAt);
|
||||
document.getElementById('joinedDate').textContent = joinedDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
|
||||
|
||||
// Stats (activities count will be loaded separately)
|
||||
// Followers/Following counts TODO: implement when federation is ready
|
||||
}
|
||||
|
||||
async function loadRecentActivities() {
|
||||
try {
|
||||
const response = await FitPubAuth.authenticatedFetch('/api/activities?page=0&size=5');
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
|
||||
document.getElementById('activitiesLoading').classList.add('d-none');
|
||||
|
||||
// Update activities count
|
||||
document.getElementById('activitiesCount').textContent = data.totalElements || 0;
|
||||
|
||||
if (data.content && data.content.length > 0) {
|
||||
renderActivities(data.content);
|
||||
document.getElementById('activitiesList').classList.remove('d-none');
|
||||
|
||||
if (data.totalElements > 5) {
|
||||
document.getElementById('viewAllActivities').classList.remove('d-none');
|
||||
}
|
||||
} else {
|
||||
document.getElementById('activitiesEmpty').classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error loading activities:', error);
|
||||
document.getElementById('activitiesLoading').classList.add('d-none');
|
||||
document.getElementById('activitiesEmpty').classList.remove('d-none');
|
||||
}
|
||||
}
|
||||
|
||||
function renderActivities(activities) {
|
||||
const list = document.getElementById('activitiesList');
|
||||
list.innerHTML = activities.map(activity => `
|
||||
<div class="activity-item mb-3 pb-3 border-bottom">
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<div class="flex-grow-1">
|
||||
<h6 class="mb-1">
|
||||
<a href="/activities/${activity.id}" class="text-decoration-none">
|
||||
${escapeHtml(activity.title || 'Untitled Activity')}
|
||||
</a>
|
||||
</h6>
|
||||
<p class="text-muted small mb-2">
|
||||
<span class="activity-type-badge activity-type-${activity.activityType.toLowerCase()}">
|
||||
${activity.activityType}
|
||||
</span>
|
||||
<span class="ms-2">
|
||||
<i class="bi bi-calendar"></i>
|
||||
${new Date(activity.startedAt).toLocaleDateString()}
|
||||
</span>
|
||||
</p>
|
||||
<div class="d-flex gap-3 text-muted small">
|
||||
<span><i class="bi bi-arrow-left-right"></i> ${formatDistance(activity.totalDistance)}</span>
|
||||
<span><i class="bi bi-clock"></i> ${formatDuration(activity.totalDuration)}</span>
|
||||
${activity.elevationGain ? `<span><i class="bi bi-arrow-up"></i> ${Math.round(activity.elevationGain)}m</span>` : ''}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
function formatDistance(meters) {
|
||||
if (!meters) return 'N/A';
|
||||
if (meters >= 1000) {
|
||||
return (meters / 1000).toFixed(1) + ' 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);
|
||||
|
||||
if (hours > 0) {
|
||||
return hours + 'h ' + minutes + 'm';
|
||||
}
|
||||
return minutes + 'm';
|
||||
}
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
div.textContent = text;
|
||||
return div.innerHTML;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
94
src/main/resources/templates/settings.html
Normal file
94
src/main/resources/templates/settings.html
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<!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>Settings</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-8">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4 class="mb-0">
|
||||
<i class="bi bi-gear"></i> Settings
|
||||
</h4>
|
||||
</div>
|
||||
<div class="card-body" id="settingsContent">
|
||||
<p class="text-muted">Settings page - Coming soon!</p>
|
||||
|
||||
<div class="list-group mt-4">
|
||||
<a href="/profile/edit" class="list-group-item list-group-item-action">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">
|
||||
<i class="bi bi-person"></i> Edit Profile
|
||||
</h5>
|
||||
<small><i class="bi bi-chevron-right"></i></small>
|
||||
</div>
|
||||
<p class="mb-1">Update your display name, bio, and avatar</p>
|
||||
</a>
|
||||
|
||||
<div class="list-group-item list-group-item-action disabled">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">
|
||||
<i class="bi bi-shield-lock"></i> Privacy Settings
|
||||
</h5>
|
||||
<small class="text-muted">Coming soon</small>
|
||||
</div>
|
||||
<p class="mb-1 text-muted">Manage your privacy and data preferences</p>
|
||||
</div>
|
||||
|
||||
<div class="list-group-item list-group-item-action disabled">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">
|
||||
<i class="bi bi-key"></i> Change Password
|
||||
</h5>
|
||||
<small class="text-muted">Coming soon</small>
|
||||
</div>
|
||||
<p class="mb-1 text-muted">Update your account password</p>
|
||||
</div>
|
||||
|
||||
<div class="list-group-item list-group-item-action disabled">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">
|
||||
<i class="bi bi-bell"></i> Notifications
|
||||
</h5>
|
||||
<small class="text-muted">Coming soon</small>
|
||||
</div>
|
||||
<p class="mb-1 text-muted">Configure notification preferences</p>
|
||||
</div>
|
||||
|
||||
<div class="list-group-item list-group-item-action disabled">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">
|
||||
<i class="bi bi-download"></i> Export Data
|
||||
</h5>
|
||||
<small class="text-muted">Coming soon</small>
|
||||
</div>
|
||||
<p class="mb-1 text-muted">Download your activities and data</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Scripts -->
|
||||
<th:block layout:fragment="scripts">
|
||||
<script th:inline="javascript">
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Redirect to login if not authenticated
|
||||
if (!FitPubAuth.isAuthenticated()) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
93
src/main/resources/templates/timeline/federated.html
Normal file
93
src/main/resources/templates/timeline/federated.html
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<!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>Federated Timeline</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>
|
||||
<i class="bi bi-people text-primary"></i>
|
||||
Following Timeline
|
||||
</h2>
|
||||
<div class="btn-group" role="group" aria-label="Timeline views">
|
||||
<a th:href="@{/timeline}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-globe"></i> Public
|
||||
</a>
|
||||
<a th:href="@{/timeline/federated}" class="btn btn-primary active">
|
||||
<i class="bi bi-people"></i> Following
|
||||
</a>
|
||||
<a th:href="@{/timeline/user}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-person"></i> My Timeline
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-muted mb-4">
|
||||
Activities from athletes you follow
|
||||
</p>
|
||||
|
||||
<!-- 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 timeline...</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>
|
||||
|
||||
<!-- Timeline Activities -->
|
||||
<div id="timelineList" class="d-none">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="emptyState" class="text-center py-5 d-none">
|
||||
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
||||
<h4 class="mt-3">No Activities Yet</h4>
|
||||
<p class="text-muted">Follow other athletes to see their activities here!</p>
|
||||
<a th:href="@{/timeline}" class="btn btn-primary mt-3">
|
||||
<i class="bi bi-globe"></i> Explore Public Timeline
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<nav id="pagination" aria-label="Timeline pagination" class="mt-4 d-none">
|
||||
<ul class="pagination justify-content-center" id="paginationList">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Scripts -->
|
||||
<th:block layout:fragment="scripts">
|
||||
<script th:src="@{/js/timeline.js}"></script>
|
||||
<script th:inline="javascript">
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Redirect to login if not authenticated
|
||||
if (!FitPubAuth.isAuthenticated()) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize timeline
|
||||
FitPubTimeline.init('federated');
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
94
src/main/resources/templates/timeline/public.html
Normal file
94
src/main/resources/templates/timeline/public.html
Normal file
|
|
@ -0,0 +1,94 @@
|
|||
<!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>Public Timeline</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>
|
||||
<i class="bi bi-globe text-primary"></i>
|
||||
Public Timeline
|
||||
</h2>
|
||||
<div class="btn-group" role="group" aria-label="Timeline views">
|
||||
<a th:href="@{/timeline}" class="btn btn-primary active">
|
||||
<i class="bi bi-globe"></i> Public
|
||||
</a>
|
||||
<a th:href="@{/timeline/federated}" class="btn btn-outline-primary" id="federatedLink" style="display: none;">
|
||||
<i class="bi bi-people"></i> Following
|
||||
</a>
|
||||
<a th:href="@{/timeline/user}" class="btn btn-outline-primary" id="userTimelineLink" style="display: none;">
|
||||
<i class="bi bi-person"></i> My Timeline
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-muted mb-4">
|
||||
Discover public fitness activities from the FitPub community
|
||||
</p>
|
||||
|
||||
<!-- 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 timeline...</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>
|
||||
|
||||
<!-- Timeline Activities -->
|
||||
<div id="timelineList" class="d-none">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="emptyState" class="text-center py-5 d-none">
|
||||
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
||||
<h4 class="mt-3">No Activities Yet</h4>
|
||||
<p class="text-muted">Be the first to share your fitness activities!</p>
|
||||
<a th:href="@{/activities/upload}" class="btn btn-primary mt-3" id="uploadLinkEmpty" style="display: none;">
|
||||
<i class="bi bi-cloud-upload"></i> Upload Activity
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<nav id="pagination" aria-label="Timeline pagination" class="mt-4 d-none">
|
||||
<ul class="pagination justify-content-center" id="paginationList">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Scripts -->
|
||||
<th:block layout:fragment="scripts">
|
||||
<script th:src="@{/js/timeline.js}"></script>
|
||||
<script th:inline="javascript">
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Show federated/user timeline links if logged in
|
||||
if (FitPubAuth.isAuthenticated()) {
|
||||
document.getElementById('federatedLink').style.display = 'inline-block';
|
||||
document.getElementById('userTimelineLink').style.display = 'inline-block';
|
||||
document.getElementById('uploadLinkEmpty').style.display = 'inline-block';
|
||||
}
|
||||
|
||||
// Initialize timeline
|
||||
FitPubTimeline.init('public');
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
93
src/main/resources/templates/timeline/user.html
Normal file
93
src/main/resources/templates/timeline/user.html
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
<!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>My Timeline</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<div layout:fragment="content">
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2>
|
||||
<i class="bi bi-person text-primary"></i>
|
||||
My Timeline
|
||||
</h2>
|
||||
<div class="btn-group" role="group" aria-label="Timeline views">
|
||||
<a th:href="@{/timeline}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-globe"></i> Public
|
||||
</a>
|
||||
<a th:href="@{/timeline/federated}" class="btn btn-outline-primary">
|
||||
<i class="bi bi-people"></i> Following
|
||||
</a>
|
||||
<a th:href="@{/timeline/user}" class="btn btn-primary active">
|
||||
<i class="bi bi-person"></i> My Timeline
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-muted mb-4">
|
||||
Your fitness activities
|
||||
</p>
|
||||
|
||||
<!-- 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 timeline...</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>
|
||||
|
||||
<!-- Timeline Activities -->
|
||||
<div id="timelineList" class="d-none">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</div>
|
||||
|
||||
<!-- Empty State -->
|
||||
<div id="emptyState" class="text-center py-5 d-none">
|
||||
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
|
||||
<h4 class="mt-3">No Activities Yet</h4>
|
||||
<p class="text-muted">Upload your first FIT file to get started!</p>
|
||||
<a th:href="@{/activities/upload}" class="btn btn-primary mt-3">
|
||||
<i class="bi bi-cloud-upload"></i> Upload Activity
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<!-- Pagination -->
|
||||
<nav id="pagination" aria-label="Timeline pagination" class="mt-4 d-none">
|
||||
<ul class="pagination justify-content-center" id="paginationList">
|
||||
<!-- Will be populated by JavaScript -->
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom Scripts -->
|
||||
<th:block layout:fragment="scripts">
|
||||
<script th:src="@{/js/timeline.js}"></script>
|
||||
<script th:inline="javascript">
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Redirect to login if not authenticated
|
||||
if (!FitPubAuth.isAuthenticated()) {
|
||||
window.location.href = '/login';
|
||||
return;
|
||||
}
|
||||
|
||||
// Initialize timeline
|
||||
FitPubTimeline.init('user');
|
||||
});
|
||||
</script>
|
||||
</th:block>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Add table
Add a link
Reference in a new issue