More vibin

This commit is contained in:
Tim Zöller 2025-11-28 21:04:38 +01:00
parent 1901daf5ce
commit c1729a629d
47 changed files with 5754 additions and 41 deletions

View file

@ -14,7 +14,7 @@ spring:
jpa:
hibernate:
ddl-auto: update
ddl-auto: validate
properties:
hibernate:
dialect: org.hibernate.dialect.PostgreSQLDialect
@ -22,6 +22,13 @@ spring:
use_sql_comments: true
show-sql: false
flyway:
enabled: true
baseline-on-migrate: true
locations: classpath:db/migration
schemas: public
validate-on-migrate: true
servlet:
multipart:
max-file-size: 50MB
@ -72,3 +79,21 @@ server:
error:
include-message: always
include-binding-errors: always
# Actuator configuration
management:
endpoints:
web:
exposure:
include: health,info
base-path: /actuator
endpoint:
health:
show-details: when-authorized
probes:
enabled: true
health:
db:
enabled: true
diskspace:
enabled: true

View file

@ -0,0 +1,7 @@
-- V1: Enable PostGIS extension for geospatial support
-- This extension is required for storing GPS track data and performing spatial queries
CREATE EXTENSION IF NOT EXISTS postgis;
-- Verify PostGIS version
SELECT PostGIS_version();

View file

@ -0,0 +1,28 @@
-- V2: Create users table
-- Stores local user accounts with ActivityPub Actor profile data
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
username VARCHAR(50) NOT NULL UNIQUE,
email VARCHAR(255) NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name VARCHAR(100),
bio TEXT,
avatar_url TEXT,
public_key TEXT NOT NULL,
private_key TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
locked BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Indexes for performance
CREATE UNIQUE INDEX idx_user_username ON users(username);
CREATE UNIQUE INDEX idx_user_email ON users(email);
CREATE INDEX idx_user_created_at ON users(created_at DESC);
-- Comment on table
COMMENT ON TABLE users IS 'Local user accounts with ActivityPub Actor profiles';
COMMENT ON COLUMN users.public_key IS 'RSA public key for ActivityPub HTTP Signature verification';
COMMENT ON COLUMN users.private_key IS 'RSA private key for signing ActivityPub requests (encrypted at rest)';

View file

@ -0,0 +1,61 @@
-- V3: Create activities table
-- Stores fitness activities with geospatial track data and metrics
CREATE TABLE activities (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
activity_type VARCHAR(50) NOT NULL,
title VARCHAR(255) NOT NULL,
description TEXT,
started_at TIMESTAMP NOT NULL,
ended_at TIMESTAMP NOT NULL,
visibility VARCHAR(20) NOT NULL DEFAULT 'PUBLIC',
-- Geospatial data
simplified_track geometry(LineString, 4326),
-- Full track data as JSONB
track_points_json JSONB,
-- Calculated metrics
total_distance NUMERIC(10, 2),
total_duration_seconds BIGINT,
elevation_gain NUMERIC(8, 2),
elevation_loss NUMERIC(8, 2),
-- Original FIT file (using OID for @Lob compatibility)
raw_fit_file OID,
-- Timestamps
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT chk_activity_type CHECK (activity_type IN (
'RUN', 'RIDE', 'HIKE', 'WALK', 'SWIM',
'ALPINE_SKI', 'BACKCOUNTRY_SKI', 'NORDIC_SKI', 'SNOWBOARD',
'ROWING', 'KAYAKING', 'CANOEING', 'INLINE_SKATING',
'ROCK_CLIMBING', 'MOUNTAINEERING', 'YOGA', 'WORKOUT', 'OTHER'
)),
CONSTRAINT chk_visibility CHECK (visibility IN ('PUBLIC', 'FOLLOWERS', 'PRIVATE')),
CONSTRAINT chk_time_range CHECK (ended_at > started_at)
);
-- Indexes for performance
CREATE INDEX idx_activity_user_id ON activities(user_id);
CREATE INDEX idx_activity_started_at ON activities(started_at DESC);
CREATE INDEX idx_activity_type ON activities(activity_type);
CREATE INDEX idx_activity_visibility ON activities(visibility);
CREATE INDEX idx_activity_user_started ON activities(user_id, started_at DESC);
-- Spatial index for geospatial queries
CREATE INDEX idx_activity_simplified_track ON activities USING GIST(simplified_track);
-- JSONB GIN index for fast JSON queries
CREATE INDEX idx_activity_track_points_json ON activities USING GIN(track_points_json);
-- Comments
COMMENT ON TABLE activities IS 'Fitness activities with GPS track data and metrics';
COMMENT ON COLUMN activities.simplified_track IS 'Simplified LineString (50-200 points) for map rendering';
COMMENT ON COLUMN activities.track_points_json IS 'Full track data with all sensors stored as JSONB';
COMMENT ON COLUMN activities.raw_fit_file IS 'Original FIT file for re-processing';

View file

@ -0,0 +1,54 @@
-- V4: Create activity_metrics table
-- Stores calculated metrics and statistics for activities
CREATE TABLE activity_metrics (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
activity_id UUID NOT NULL UNIQUE REFERENCES activities(id) ON DELETE CASCADE,
-- Speed metrics
average_speed NUMERIC(8, 2),
max_speed NUMERIC(8, 2),
average_pace_seconds BIGINT,
-- Heart rate metrics
average_heart_rate INTEGER,
max_heart_rate INTEGER,
-- Cadence metrics
average_cadence INTEGER,
max_cadence INTEGER,
-- Power metrics
average_power INTEGER,
max_power INTEGER,
normalized_power INTEGER,
-- Other metrics
calories INTEGER,
average_temperature NUMERIC(5, 2),
-- Elevation metrics
max_elevation NUMERIC(8, 2),
min_elevation NUMERIC(8, 2),
total_ascent NUMERIC(8, 2),
total_descent NUMERIC(8, 2),
-- Time metrics
moving_time_seconds BIGINT,
stopped_time_seconds BIGINT,
-- Step counter
total_steps INTEGER,
-- Training metrics
training_stress_score NUMERIC(8, 2)
);
-- Index on activity_id for fast lookup
CREATE UNIQUE INDEX idx_activity_metrics_activity_id ON activity_metrics(activity_id);
-- Comments
COMMENT ON TABLE activity_metrics IS 'Calculated metrics and statistics for activities';
COMMENT ON COLUMN activity_metrics.average_pace_seconds IS 'Average pace in seconds per kilometer';
COMMENT ON COLUMN activity_metrics.normalized_power IS 'Normalized Power (NP) for cycling power analysis';
COMMENT ON COLUMN activity_metrics.training_stress_score IS 'TSS - Training Stress Score';

View file

@ -0,0 +1,31 @@
-- V5: Create follows table
-- Stores follow relationships between local and remote actors
CREATE TABLE follows (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
follower_id UUID REFERENCES users(id) ON DELETE CASCADE,
following_actor_uri VARCHAR(512) NOT NULL,
status VARCHAR(20) NOT NULL DEFAULT 'PENDING',
activity_id VARCHAR(512),
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
-- Constraints
CONSTRAINT chk_follow_status CHECK (status IN ('PENDING', 'ACCEPTED', 'REJECTED'))
);
-- Indexes for performance
CREATE INDEX idx_follower_id ON follows(follower_id);
CREATE INDEX idx_following_actor_uri ON follows(following_actor_uri);
CREATE INDEX idx_follow_status ON follows(status);
CREATE INDEX idx_follow_activity_id ON follows(activity_id);
-- Unique constraint to prevent duplicate follows
CREATE UNIQUE INDEX idx_unique_follow ON follows(follower_id, following_actor_uri)
WHERE follower_id IS NOT NULL;
-- Comments
COMMENT ON TABLE follows IS 'Follow relationships between local and remote actors for ActivityPub federation';
COMMENT ON COLUMN follows.follower_id IS 'Local user ID (null for remote followers)';
COMMENT ON COLUMN follows.following_actor_uri IS 'ActivityPub actor URI of the followed user';
COMMENT ON COLUMN follows.activity_id IS 'ActivityPub activity ID for the follow request';
COMMENT ON COLUMN follows.status IS 'Status of the follow relationship';

View file

@ -0,0 +1,33 @@
-- V6: Create remote_actors table
-- Caches remote ActivityPub actor information for federation
CREATE TABLE remote_actors (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
actor_uri VARCHAR(512) NOT NULL UNIQUE,
username VARCHAR(255) NOT NULL,
domain VARCHAR(255) NOT NULL,
inbox_url VARCHAR(512) NOT NULL,
outbox_url VARCHAR(512),
shared_inbox_url VARCHAR(512),
public_key TEXT NOT NULL,
public_key_id VARCHAR(512),
display_name VARCHAR(255),
avatar_url VARCHAR(512),
summary TEXT,
last_fetched_at TIMESTAMP,
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
updated_at TIMESTAMP NOT NULL DEFAULT NOW()
);
-- Indexes for performance
CREATE UNIQUE INDEX idx_actor_uri ON remote_actors(actor_uri);
CREATE INDEX idx_domain ON remote_actors(domain);
CREATE INDEX idx_username_domain ON remote_actors(username, domain);
CREATE INDEX idx_last_fetched_at ON remote_actors(last_fetched_at);
-- Comments
COMMENT ON TABLE remote_actors IS 'Cache of remote ActivityPub actor profiles for federation';
COMMENT ON COLUMN remote_actors.actor_uri IS 'Full ActivityPub actor URI (e.g., https://mastodon.social/users/username)';
COMMENT ON COLUMN remote_actors.shared_inbox_url IS 'Shared inbox URL for efficient server-to-server communication';
COMMENT ON COLUMN remote_actors.public_key IS 'RSA public key for HTTP Signature verification';
COMMENT ON COLUMN remote_actors.last_fetched_at IS 'Timestamp of last actor profile fetch for cache invalidation';

View file

@ -0,0 +1,207 @@
/* FitPub - Custom Styles */
:root {
--primary-color: #2563eb;
--secondary-color: #10b981;
--danger-color: #ef4444;
--warning-color: #f59e0b;
--dark-color: #1f2937;
--light-color: #f3f4f6;
--border-radius: 0.5rem;
}
/* Base styles */
body {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
color: var(--dark-color);
}
/* Navigation */
.navbar-brand {
font-weight: 700;
font-size: 1.5rem;
}
/* Map container */
.map-container {
height: 400px;
border-radius: var(--border-radius);
overflow: hidden;
margin-bottom: 1rem;
}
.map-container-large {
height: 600px;
}
/* Activity cards */
.activity-card {
transition: transform 0.2s, box-shadow 0.2s;
}
.activity-card:hover {
transform: translateY(-2px);
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
}
.activity-type-badge {
display: inline-block;
padding: 0.25rem 0.75rem;
border-radius: 1rem;
font-size: 0.875rem;
font-weight: 600;
}
.activity-type-run {
background-color: #dbeafe;
color: #1e40af;
}
.activity-type-ride {
background-color: #fef3c7;
color: #92400e;
}
.activity-type-hike {
background-color: #d1fae5;
color: #065f46;
}
/* Metrics display */
.metric-card {
background: var(--light-color);
border-radius: var(--border-radius);
padding: 1rem;
text-align: center;
}
.metric-value {
font-size: 2rem;
font-weight: 700;
color: var(--primary-color);
}
.metric-label {
font-size: 0.875rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
}
/* File upload area */
.file-upload-area {
border: 2px dashed #d1d5db;
border-radius: var(--border-radius);
padding: 3rem 2rem;
text-align: center;
transition: border-color 0.2s, background-color 0.2s;
cursor: pointer;
}
.file-upload-area:hover,
.file-upload-area.drag-over {
border-color: var(--primary-color);
background-color: #eff6ff;
}
.file-upload-icon {
font-size: 3rem;
color: #9ca3af;
}
/* Timeline */
.timeline-item {
border-left: 3px solid var(--light-color);
padding-left: 1.5rem;
margin-bottom: 2rem;
position: relative;
}
.timeline-item::before {
content: '';
position: absolute;
left: -0.5rem;
top: 0.5rem;
width: 1rem;
height: 1rem;
border-radius: 50%;
background-color: var(--primary-color);
border: 3px solid white;
}
.timeline-date {
color: #6b7280;
font-size: 0.875rem;
font-weight: 600;
}
/* Charts */
.chart-container {
position: relative;
height: 300px;
margin-bottom: 1rem;
}
/* Loading states */
.loading-spinner {
display: inline-block;
width: 2rem;
height: 2rem;
border: 3px solid rgba(0, 0, 0, 0.1);
border-radius: 50%;
border-top-color: var(--primary-color);
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* HTMX loading indicator */
.htmx-indicator {
display: none;
}
.htmx-request .htmx-indicator {
display: inline-block;
}
.htmx-request.htmx-indicator {
display: inline-block;
}
/* Utility classes */
.text-muted {
color: #6b7280;
}
.text-small {
font-size: 0.875rem;
}
.visibility-public {
color: var(--secondary-color);
}
.visibility-followers {
color: var(--warning-color);
}
.visibility-private {
color: var(--danger-color);
}
/* Responsive adjustments */
@media (max-width: 768px) {
.map-container {
height: 300px;
}
.map-container-large {
height: 400px;
}
.metric-value {
font-size: 1.5rem;
}
}

View file

@ -0,0 +1,308 @@
// FitPub - Authentication Management
/**
* Authentication utilities for managing JWT tokens and user sessions
*/
const FitPubAuth = {
/**
* Get the stored JWT token
* @returns {string|null} JWT token or null if not found
*/
getToken: function() {
return localStorage.getItem('jwtToken');
},
/**
* Store JWT token
* @param {string} token - JWT token to store
*/
setToken: function(token) {
localStorage.setItem('jwtToken', token);
},
/**
* Remove stored JWT token
*/
removeToken: function() {
localStorage.removeItem('jwtToken');
localStorage.removeItem('username');
},
/**
* Get the stored username
* @returns {string|null} Username or null if not found
*/
getUsername: function() {
return localStorage.getItem('username');
},
/**
* Store username
* @param {string} username - Username to store
*/
setUsername: function(username) {
localStorage.setItem('username', username);
},
/**
* Check if user is authenticated
* @returns {boolean} True if authenticated, false otherwise
*/
isAuthenticated: function() {
const token = this.getToken();
if (!token) {
return false;
}
// Check if token is expired
try {
const payload = this.parseJwt(token);
const now = Math.floor(Date.now() / 1000);
if (payload.exp && payload.exp < now) {
// Token expired, remove it
this.removeToken();
return false;
}
return true;
} catch (e) {
console.error('Error parsing JWT:', e);
this.removeToken();
return false;
}
},
/**
* Parse JWT token to extract payload
* @param {string} token - JWT token
* @returns {object} Decoded payload
*/
parseJwt: function(token) {
try {
const base64Url = token.split('.')[1];
const base64 = base64Url.replace(/-/g, '+').replace(/_/g, '/');
const jsonPayload = decodeURIComponent(
atob(base64).split('').map(function(c) {
return '%' + ('00' + c.charCodeAt(0).toString(16)).slice(-2);
}).join('')
);
return JSON.parse(jsonPayload);
} catch (e) {
console.error('Error parsing JWT:', e);
throw e;
}
},
/**
* Get time until token expiration
* @returns {number} Seconds until expiration, or 0 if expired/invalid
*/
getTokenExpirationTime: function() {
const token = this.getToken();
if (!token) {
return 0;
}
try {
const payload = this.parseJwt(token);
const now = Math.floor(Date.now() / 1000);
if (payload.exp) {
return Math.max(0, payload.exp - now);
}
return 0;
} catch (e) {
return 0;
}
},
/**
* Logout user
*/
logout: function() {
this.removeToken();
window.location.href = '/login';
},
/**
* Make an authenticated API request
* @param {string} url - API endpoint URL
* @param {object} options - Fetch options
* @returns {Promise<Response>} Fetch response
*/
authenticatedFetch: async function(url, options = {}) {
const token = this.getToken();
if (!token) {
throw new Error('No authentication token found');
}
// Add Authorization header
const headers = {
...options.headers,
'Authorization': `Bearer ${token}`,
};
// If body is an object, set Content-Type to JSON
if (options.body && typeof options.body === 'object') {
headers['Content-Type'] = 'application/json';
options.body = JSON.stringify(options.body);
}
const response = await fetch(url, {
...options,
headers
});
// If unauthorized, redirect to login
if (response.status === 401) {
this.removeToken();
window.location.href = '/login';
throw new Error('Authentication failed');
}
return response;
},
/**
* Initialize authentication checks and setup
*/
init: function() {
// Update navigation UI based on auth status
this.updateNavigationUI();
// Check authentication status on page load
this.checkAuthStatus();
// Set up session expiration warning
this.setupExpirationWarning();
},
/**
* Update navigation UI based on authentication status
*/
updateNavigationUI: function() {
const authUserMenu = document.getElementById('authUserMenu');
const guestMenu = document.getElementById('guestMenu');
const usernameDisplay = document.getElementById('usernameDisplay');
const myActivitiesLink = document.getElementById('myActivitiesLink');
const uploadLink = document.getElementById('uploadLink');
if (this.isAuthenticated()) {
// Show authenticated menu, hide guest menu
if (authUserMenu) {
authUserMenu.classList.remove('d-none');
}
if (guestMenu) {
guestMenu.style.display = 'none';
}
// Show authenticated navigation links
if (myActivitiesLink) {
myActivitiesLink.style.display = '';
myActivitiesLink.parentElement.style.display = '';
}
if (uploadLink) {
uploadLink.style.display = '';
uploadLink.parentElement.style.display = '';
}
// Display username
const username = this.getUsername();
if (usernameDisplay && username) {
usernameDisplay.textContent = username;
}
} else {
// Show guest menu, hide authenticated menu
if (authUserMenu) {
authUserMenu.classList.add('d-none');
}
if (guestMenu) {
guestMenu.style.display = '';
}
// Hide authenticated navigation links
if (myActivitiesLink) {
myActivitiesLink.style.display = 'none';
myActivitiesLink.parentElement.style.display = 'none';
}
if (uploadLink) {
uploadLink.style.display = 'none';
uploadLink.parentElement.style.display = 'none';
}
}
},
/**
* Check authentication status and handle accordingly
*/
checkAuthStatus: function() {
const currentPath = window.location.pathname;
const publicPaths = ['/', '/login', '/register', '/timeline'];
// Skip check for public paths
if (publicPaths.includes(currentPath)) {
return;
}
// Check if authenticated
if (!this.isAuthenticated()) {
// Redirect to login for protected pages
window.location.href = '/login?redirect=' + encodeURIComponent(currentPath);
}
},
/**
* Set up warning for session expiration
*/
setupExpirationWarning: function() {
const token = this.getToken();
if (!token) {
return;
}
const expirationTime = this.getTokenExpirationTime();
if (expirationTime > 0) {
// Warn 5 minutes before expiration
const warningTime = Math.max(0, (expirationTime - 300) * 1000);
setTimeout(() => {
if (this.isAuthenticated()) {
this.showExpirationWarning();
}
}, warningTime);
}
},
/**
* Show session expiration warning
*/
showExpirationWarning: function() {
if (window.FitPub && window.FitPub.showAlert) {
window.FitPub.showAlert(
'Your session will expire soon. Please save your work.',
'warning'
);
} else {
console.warn('Session expiring soon');
}
},
/**
* Refresh the current page with authentication
*/
refreshPage: function() {
window.location.reload();
}
};
// Initialize authentication on page load
document.addEventListener('DOMContentLoaded', function() {
FitPubAuth.init();
});
// Make available globally
window.FitPubAuth = FitPubAuth;

View file

@ -0,0 +1,468 @@
// FitPub - Main JavaScript
/**
* Initialize application when DOM is ready
*/
document.addEventListener('DOMContentLoaded', function() {
console.log('FitPub initialized');
// Initialize file upload areas
initFileUploadAreas();
// Initialize HTMX event listeners
initHtmxListeners();
});
/**
* Initialize drag-and-drop file upload areas
*/
function initFileUploadAreas() {
const uploadAreas = document.querySelectorAll('.file-upload-area');
uploadAreas.forEach(area => {
const fileInput = area.querySelector('input[type="file"]');
// Drag and drop events
area.addEventListener('dragover', (e) => {
e.preventDefault();
area.classList.add('drag-over');
});
area.addEventListener('dragleave', (e) => {
e.preventDefault();
area.classList.remove('drag-over');
});
area.addEventListener('drop', (e) => {
e.preventDefault();
area.classList.remove('drag-over');
if (e.dataTransfer.files.length > 0) {
fileInput.files = e.dataTransfer.files;
updateFileInputLabel(fileInput);
}
});
// Click to upload
area.addEventListener('click', () => {
fileInput.click();
});
// File input change
if (fileInput) {
fileInput.addEventListener('change', () => {
updateFileInputLabel(fileInput);
});
}
});
}
/**
* Update file input label with selected file name
*/
function updateFileInputLabel(input) {
const label = input.parentElement.querySelector('.file-upload-label');
if (label && input.files.length > 0) {
const fileName = input.files[0].name;
label.textContent = fileName;
}
}
/**
* Initialize HTMX event listeners for custom behavior
*/
function initHtmxListeners() {
// Show loading indicator on HTMX requests
document.body.addEventListener('htmx:beforeRequest', (event) => {
console.log('HTMX request started:', event.detail.path);
});
// Hide loading indicator when request completes
document.body.addEventListener('htmx:afterRequest', (event) => {
console.log('HTMX request completed:', event.detail.path);
});
// Handle HTMX errors
document.body.addEventListener('htmx:responseError', (event) => {
console.error('HTMX error:', event.detail);
showAlert('An error occurred. Please try again.', 'danger');
});
// Scroll to top after swapping content
document.body.addEventListener('htmx:afterSwap', (event) => {
if (event.detail.target.id === 'main-content') {
window.scrollTo({ top: 0, behavior: 'smooth' });
}
});
}
/**
* Create and render a Leaflet map with a GPS track
*
* @param {string} containerId - The ID of the map container element
* @param {Object} geoJsonData - GeoJSON track data (LineString or FeatureCollection)
* @param {Object} options - Map options
* @param {boolean} options.showStartEnd - Show start/finish markers (default: true)
* @param {boolean} options.fitBounds - Auto-fit map to track bounds (default: true)
* @param {Function} options.onTrackClick - Callback when track is clicked
* @returns {Object} Leaflet map instance
*/
function createActivityMap(containerId, geoJsonData, options = {}) {
const container = document.getElementById(containerId);
if (!container) {
console.error('Map container not found:', containerId);
return null;
}
// Clear any existing map instance
if (container._leaflet_id) {
container._leaflet_id = undefined;
container.innerHTML = '';
}
// Default options
const defaultOptions = {
zoomControl: true,
attributionControl: true,
scrollWheelZoom: true,
showStartEnd: true,
fitBounds: true
};
const mapOptions = { ...defaultOptions, ...options };
// Initialize Leaflet map
const map = L.map(containerId, {
zoomControl: mapOptions.zoomControl,
attributionControl: mapOptions.attributionControl,
scrollWheelZoom: mapOptions.scrollWheelZoom
});
// Add OpenStreetMap tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors',
maxZoom: 19,
minZoom: 3
}).addTo(map);
// Add GeoJSON track if provided
if (geoJsonData) {
let trackLayer;
// Handle both GeoJSON FeatureCollection and plain LineString
if (geoJsonData.type === 'LineString') {
trackLayer = L.geoJSON({
type: 'Feature',
geometry: geoJsonData,
properties: {}
}, {
style: {
color: '#2563eb',
weight: 4,
opacity: 0.8,
lineCap: 'round',
lineJoin: 'round'
},
onEachFeature: (feature, layer) => {
// Add click handler if provided
if (mapOptions.onTrackClick) {
layer.on('click', (e) => {
mapOptions.onTrackClick(e, feature);
});
}
}
}).addTo(map);
} else {
trackLayer = L.geoJSON(geoJsonData, {
style: {
color: '#2563eb',
weight: 4,
opacity: 0.8,
lineCap: 'round',
lineJoin: 'round'
},
onEachFeature: (feature, layer) => {
// Add popups with point-in-time metrics if available
if (feature.properties) {
const props = feature.properties;
let popupContent = '<div class="map-popup">';
if (props.time) {
popupContent += `<strong>Time:</strong> ${new Date(props.time).toLocaleTimeString()}<br>`;
}
if (props.heartRate) {
popupContent += `<strong>Heart Rate:</strong> ${props.heartRate} bpm<br>`;
}
if (props.speed !== undefined) {
const speedKmh = props.speed * 3.6;
popupContent += `<strong>Speed:</strong> ${speedKmh.toFixed(2)} km/h<br>`;
}
if (props.elevation !== undefined) {
popupContent += `<strong>Elevation:</strong> ${props.elevation.toFixed(1)} m<br>`;
}
if (props.cadence) {
popupContent += `<strong>Cadence:</strong> ${props.cadence} rpm<br>`;
}
popupContent += '</div>';
layer.bindPopup(popupContent);
}
// Add click handler if provided
if (mapOptions.onTrackClick) {
layer.on('click', (e) => {
mapOptions.onTrackClick(e, feature);
});
}
}
}).addTo(map);
}
// Fit map bounds to track
if (mapOptions.fitBounds) {
try {
const bounds = trackLayer.getBounds();
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [50, 50] });
}
} catch (e) {
console.warn('Could not fit map bounds:', e);
map.setView([0, 0], 2);
}
}
// Add start/finish markers
if (mapOptions.showStartEnd) {
addStartFinishMarkers(map, geoJsonData);
}
// Store track layer reference for potential future use
map.trackLayer = trackLayer;
} else {
// No track data, show default view
map.setView([0, 0], 2);
}
// Invalidate size to ensure proper rendering
setTimeout(() => {
map.invalidateSize();
}, 100);
return map;
}
/**
* Add start and finish markers to the map
*
* @param {Object} map - Leaflet map instance
* @param {Object} geoJsonData - GeoJSON track data
*/
function addStartFinishMarkers(map, geoJsonData) {
if (!geoJsonData) {
return;
}
let coordinates;
// Handle both LineString and FeatureCollection
if (geoJsonData.type === 'LineString') {
coordinates = geoJsonData.coordinates;
} else if (geoJsonData.type === 'Feature') {
coordinates = geoJsonData.geometry.coordinates;
} else if (geoJsonData.type === 'FeatureCollection' && geoJsonData.features && geoJsonData.features.length > 0) {
coordinates = geoJsonData.features[0].geometry.coordinates;
}
if (!coordinates || coordinates.length < 2) {
return;
}
// Start marker (green)
const startCoord = coordinates[0];
const startMarker = L.marker([startCoord[1], startCoord[0]], {
icon: L.divIcon({
className: 'start-finish-marker',
html: `<div style="
background-color: #10b981;
width: 24px;
height: 24px;
border-radius: 50%;
border: 3px solid white;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
"></div>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
}),
title: 'Start'
}).addTo(map);
startMarker.bindPopup('<strong>Start</strong>');
// Finish marker (red)
const finishCoord = coordinates[coordinates.length - 1];
const finishMarker = L.marker([finishCoord[1], finishCoord[0]], {
icon: L.divIcon({
className: 'start-finish-marker',
html: `<div style="
background-color: #ef4444;
width: 24px;
height: 24px;
border-radius: 50%;
border: 3px solid white;
box-shadow: 0 2px 4px rgba(0,0,0,0.3);
"></div>`,
iconSize: [24, 24],
iconAnchor: [12, 12]
}),
title: 'Finish'
}).addTo(map);
finishMarker.bindPopup('<strong>Finish</strong>');
}
/**
* Create an elevation profile chart
*
* @param {string} canvasId - The ID of the canvas element
* @param {Array} elevationData - Array of {distance, elevation} objects
*/
function createElevationChart(canvasId, elevationData) {
const ctx = document.getElementById(canvasId);
if (!ctx) {
console.error('Chart canvas not found:', canvasId);
return null;
}
return new Chart(ctx, {
type: 'line',
data: {
labels: elevationData.map(d => (d.distance / 1000).toFixed(2)),
datasets: [{
label: 'Elevation (m)',
data: elevationData.map(d => d.elevation),
borderColor: '#10b981',
backgroundColor: 'rgba(16, 185, 129, 0.1)',
fill: true,
tension: 0.4
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
title: (context) => {
return `Distance: ${context[0].label} km`;
},
label: (context) => {
return `Elevation: ${context.parsed.y.toFixed(1)} m`;
}
}
}
},
scales: {
x: {
title: {
display: true,
text: 'Distance (km)'
}
},
y: {
title: {
display: true,
text: 'Elevation (m)'
}
}
}
}
});
}
/**
* Show an alert message
*
* @param {string} message - The message to display
* @param {string} type - Alert type: success, danger, warning, info
*/
function showAlert(message, type = 'info') {
const alertDiv = document.createElement('div');
alertDiv.className = `alert alert-${type} alert-dismissible fade show`;
alertDiv.setAttribute('role', 'alert');
alertDiv.innerHTML = `
${message}
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
`;
const container = document.querySelector('main.container');
if (container) {
container.insertBefore(alertDiv, container.firstChild);
// Auto-dismiss after 5 seconds
setTimeout(() => {
alertDiv.classList.remove('show');
setTimeout(() => alertDiv.remove(), 150);
}, 5000);
}
}
/**
* Format duration from seconds to human-readable string
*
* @param {number} seconds - Duration in seconds
* @returns {string} Formatted duration (e.g., "1h 23m 45s")
*/
function formatDuration(seconds) {
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
const parts = [];
if (hours > 0) parts.push(`${hours}h`);
if (minutes > 0) parts.push(`${minutes}m`);
if (secs > 0 || parts.length === 0) parts.push(`${secs}s`);
return parts.join(' ');
}
/**
* Format distance in meters to human-readable string
*
* @param {number} meters - Distance in meters
* @returns {string} Formatted distance (e.g., "12.34 km" or "856 m")
*/
function formatDistance(meters) {
if (meters >= 1000) {
return `${(meters / 1000).toFixed(2)} km`;
}
return `${Math.round(meters)} m`;
}
/**
* Format pace from m/s to min/km
*
* @param {number} speed - Speed in m/s
* @returns {string} Formatted pace (e.g., "5:23 /km")
*/
function formatPace(speed) {
if (speed === 0) return '--';
const paceSeconds = 1000 / speed;
const minutes = Math.floor(paceSeconds / 60);
const seconds = Math.floor(paceSeconds % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')} /km`;
}
// Make functions available globally for inline scripts
window.FitPub = {
createActivityMap,
createElevationChart,
showAlert,
formatDuration,
formatDistance,
formatPace
};

View file

@ -0,0 +1,443 @@
<!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>Activity Details</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 activity...</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>
<!-- Activity Content -->
<div id="activityContent" class="d-none">
<!-- Activity Header -->
<div class="row mb-4">
<div class="col-12">
<div class="d-flex justify-content-between align-items-start">
<div>
<h2 id="activityTitle">Activity Title</h2>
<p class="text-muted mb-2">
<span id="activityType" class="activity-type-badge"></span>
<span class="ms-2">
<i class="bi bi-calendar"></i>
<span id="activityDate"></span>
</span>
<span class="ms-2" id="visibilityBadge">
<i class="bi bi-globe"></i>
<span id="activityVisibility"></span>
</span>
</p>
<p id="activityDescription" class="text-muted"></p>
</div>
<div class="btn-group" role="group">
<a href="#" id="editBtn" class="btn btn-outline-primary">
<i class="bi bi-pencil"></i> Edit
</a>
<button id="deleteBtn" class="btn btn-outline-danger">
<i class="bi bi-trash"></i> Delete
</button>
</div>
</div>
</div>
</div>
<!-- Activity Metrics -->
<div class="row mb-4">
<div class="col-md-3 col-6 mb-3">
<div class="card text-center">
<div class="card-body">
<h3 class="text-primary mb-0" id="metricDistance">--</h3>
<p class="text-muted mb-0">Distance</p>
</div>
</div>
</div>
<div class="col-md-3 col-6 mb-3">
<div class="card text-center">
<div class="card-body">
<h3 class="text-primary mb-0" id="metricDuration">--</h3>
<p class="text-muted mb-0">Duration</p>
</div>
</div>
</div>
<div class="col-md-3 col-6 mb-3">
<div class="card text-center">
<div class="card-body">
<h3 class="text-primary mb-0" id="metricElevation">--</h3>
<p class="text-muted mb-0">Elevation Gain</p>
</div>
</div>
</div>
<div class="col-md-3 col-6 mb-3">
<div class="card text-center">
<div class="card-body">
<h3 class="text-primary mb-0" id="metricPace">--</h3>
<p class="text-muted mb-0">Avg Pace</p>
</div>
</div>
</div>
</div>
<!-- Map -->
<div class="row mb-4">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-map"></i> Route Map
</h5>
</div>
<div class="card-body p-0">
<div id="activityMap" class="map-container-large"></div>
</div>
</div>
</div>
</div>
<!-- Elevation Chart -->
<div class="row mb-4" id="elevationSection" style="display: none;">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-graph-up"></i> Elevation Profile
</h5>
</div>
<div class="card-body">
<canvas id="elevationChart" height="100"></canvas>
</div>
</div>
</div>
</div>
<!-- Additional Metrics -->
<div class="row mb-4" id="additionalMetrics" style="display: none;">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-speedometer2"></i> Additional Metrics
</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-4 mb-3" id="avgHeartRateContainer" style="display: none;">
<strong>Average Heart Rate:</strong>
<span id="avgHeartRate" class="float-end">-- bpm</span>
</div>
<div class="col-md-4 mb-3" id="maxHeartRateContainer" style="display: none;">
<strong>Max Heart Rate:</strong>
<span id="maxHeartRate" class="float-end">-- bpm</span>
</div>
<div class="col-md-4 mb-3" id="avgCadenceContainer" style="display: none;">
<strong>Average Cadence:</strong>
<span id="avgCadence" class="float-end">-- rpm</span>
</div>
<div class="col-md-4 mb-3" id="avgSpeedContainer" style="display: none;">
<strong>Average Speed:</strong>
<span id="avgSpeed" class="float-end">-- km/h</span>
</div>
<div class="col-md-4 mb-3" id="maxSpeedContainer" style="display: none;">
<strong>Max Speed:</strong>
<span id="maxSpeed" class="float-end">-- km/h</span>
</div>
<div class="col-md-4 mb-3" id="caloriesContainer" style="display: none;">
<strong>Calories:</strong>
<span id="calories" class="float-end">-- kcal</span>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Back Button -->
<div class="row">
<div class="col-12">
<a th:href="@{/activities}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Back to Activities
</a>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">
<i class="bi bi-exclamation-triangle text-danger"></i>
Delete Activity
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete this activity?</p>
<p class="text-danger mb-0"><strong>This action cannot be undone.</strong></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
<i class="bi bi-trash"></i> Delete
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Custom Scripts -->
<th:block layout:fragment="scripts">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
const activityId = window.location.pathname.split('/').pop();
const loadingIndicator = document.getElementById('loadingIndicator');
const errorAlert = document.getElementById('errorAlert');
const errorMessage = document.getElementById('errorMessage');
const activityContent = document.getElementById('activityContent');
// Load activity details
loadActivity();
async function loadActivity() {
try {
const response = await FitPubAuth.authenticatedFetch(`/api/activities/${activityId}`);
if (response.ok) {
const activity = await response.json();
renderActivity(activity);
// Hide loading, show content
loadingIndicator.classList.add('d-none');
activityContent.classList.remove('d-none');
} else {
throw new Error('Failed to load activity');
}
} catch (error) {
console.error('Error loading activity:', error);
loadingIndicator.classList.add('d-none');
errorMessage.textContent = 'Failed to load activity. Please try again.';
errorAlert.classList.remove('d-none');
}
}
function renderActivity(activity) {
// Header
document.getElementById('activityTitle').textContent = activity.title || 'Untitled Activity';
document.getElementById('activityType').textContent = activity.activityType;
document.getElementById('activityType').className = `activity-type-badge activity-type-${activity.activityType.toLowerCase()}`;
document.getElementById('activityDate').textContent = new Date(activity.startedAt).toLocaleString();
document.getElementById('activityVisibility').textContent = activity.visibility;
// Visibility icon
const visIcon = getVisibilityIcon(activity.visibility);
document.querySelector('#visibilityBadge i').className = `bi bi-${visIcon}`;
document.getElementById('visibilityBadge').className = `ms-2 visibility-${activity.visibility.toLowerCase()}`;
// Description
if (activity.description) {
document.getElementById('activityDescription').textContent = activity.description;
} else {
document.getElementById('activityDescription').style.display = 'none';
}
// Edit button
document.getElementById('editBtn').href = `/activities/${activity.id}/edit`;
// Metrics
document.getElementById('metricDistance').textContent = formatDistance(activity.totalDistance);
document.getElementById('metricDuration').textContent = formatDuration(activity.totalDuration);
document.getElementById('metricElevation').textContent = activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A';
// Calculate pace
if (activity.totalDistance && activity.totalDuration) {
const paceSeconds = activity.totalDuration / (activity.totalDistance / 1000);
document.getElementById('metricPace').textContent = formatPace(paceSeconds);
}
// Render map if track data exists
if (activity.simplifiedTrack) {
renderMap(activity.simplifiedTrack);
}
// Render elevation chart if data exists
if (activity.trackPoints && activity.trackPoints.length > 0) {
const hasElevation = activity.trackPoints.some(p => p.elevation != null);
if (hasElevation) {
document.getElementById('elevationSection').style.display = 'block';
renderElevationChart(activity.trackPoints);
}
}
// Additional metrics
renderAdditionalMetrics(activity);
}
function renderMap(simplifiedTrack) {
// Parse GeoJSON from simplifiedTrack
const geoJson = {
type: 'LineString',
coordinates: simplifiedTrack.coordinates
};
// Create map
FitPub.createActivityMap('activityMap', geoJson, {
showStartEnd: true,
fitBounds: true
});
}
function renderElevationChart(trackPoints) {
const elevationData = trackPoints
.filter(p => p.elevation != null)
.map((p, index) => ({
x: index,
y: p.elevation
}));
if (elevationData.length > 0) {
FitPub.createElevationChart('elevationChart', elevationData);
}
}
function renderAdditionalMetrics(activity) {
let hasAdditionalMetrics = false;
// Average Heart Rate
if (activity.averageHeartRate) {
document.getElementById('avgHeartRate').textContent = Math.round(activity.averageHeartRate) + ' bpm';
document.getElementById('avgHeartRateContainer').style.display = 'block';
hasAdditionalMetrics = true;
}
// Max Heart Rate
if (activity.maxHeartRate) {
document.getElementById('maxHeartRate').textContent = Math.round(activity.maxHeartRate) + ' bpm';
document.getElementById('maxHeartRateContainer').style.display = 'block';
hasAdditionalMetrics = true;
}
// Average Cadence
if (activity.averageCadence) {
document.getElementById('avgCadence').textContent = Math.round(activity.averageCadence) + ' rpm';
document.getElementById('avgCadenceContainer').style.display = 'block';
hasAdditionalMetrics = true;
}
// Average Speed
if (activity.averageSpeed) {
document.getElementById('avgSpeed').textContent = (activity.averageSpeed * 3.6).toFixed(1) + ' km/h';
document.getElementById('avgSpeedContainer').style.display = 'block';
hasAdditionalMetrics = true;
}
// Max Speed
if (activity.maxSpeed) {
document.getElementById('maxSpeed').textContent = (activity.maxSpeed * 3.6).toFixed(1) + ' km/h';
document.getElementById('maxSpeedContainer').style.display = 'block';
hasAdditionalMetrics = true;
}
// Calories
if (activity.calories) {
document.getElementById('calories').textContent = Math.round(activity.calories) + ' kcal';
document.getElementById('caloriesContainer').style.display = 'block';
hasAdditionalMetrics = true;
}
if (hasAdditionalMetrics) {
document.getElementById('additionalMetrics').style.display = 'block';
}
}
// Delete functionality
document.getElementById('deleteBtn').addEventListener('click', function() {
const modal = new bootstrap.Modal(document.getElementById('deleteModal'));
modal.show();
});
document.getElementById('confirmDeleteBtn').addEventListener('click', async function() {
try {
const response = await FitPubAuth.authenticatedFetch(
`/api/activities/${activityId}`,
{ method: 'DELETE' }
);
if (response.ok) {
// Close modal and redirect
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteModal'));
modal.hide();
FitPub.showAlert('Activity deleted successfully', 'success');
setTimeout(() => {
window.location.href = '/activities';
}, 1000);
} else {
throw new Error('Failed to delete activity');
}
} catch (error) {
console.error('Delete error:', error);
FitPub.showAlert('Failed to delete activity. Please try again.', 'danger');
}
});
// Helper functions
function formatDistance(meters) {
if (!meters) return 'N/A';
if (meters >= 1000) {
return (meters / 1000).toFixed(2) + ' 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);
const secs = Math.floor(seconds % 60);
const parts = [];
if (hours > 0) parts.push(hours + 'h');
if (minutes > 0) parts.push(minutes + 'm');
if (secs > 0 || parts.length === 0) parts.push(secs + 's');
return parts.join(' ');
}
function formatPace(secondsPerKm) {
if (!secondsPerKm) return 'N/A';
const minutes = Math.floor(secondsPerKm / 60);
const seconds = Math.floor(secondsPerKm % 60);
return `${minutes}:${seconds.toString().padStart(2, '0')}/km`;
}
function getVisibilityIcon(visibility) {
switch (visibility) {
case 'PUBLIC': return 'globe';
case 'FOLLOWERS': return 'people';
case 'PRIVATE': return 'lock';
default: return 'question-circle';
}
}
});
</script>
</th:block>
</body>
</html>

View file

@ -0,0 +1,330 @@
<!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 Activity</title>
</head>
<body>
<div layout:fragment="content">
<div class="row justify-content-center">
<div class="col-lg-8">
<h2 class="mb-4">
<i class="bi bi-pencil text-primary"></i>
Edit Activity
</h2>
<!-- 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 activity...</p>
</div>
<!-- Success Alert -->
<div id="successAlert" class="alert alert-success d-none" role="alert">
<i class="bi bi-check-circle-fill"></i>
<span id="successMessage">Activity updated successfully!</span>
</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>
<!-- Edit Form -->
<div class="card shadow-sm d-none" id="editCard">
<div class="card-body p-4">
<form id="editForm">
<!-- Title -->
<div class="mb-3">
<label for="title" class="form-label">
Title <span class="text-danger">*</span>
</label>
<input type="text"
class="form-control"
id="title"
name="title"
placeholder="e.g., Morning Run"
maxlength="200"
required>
<div class="invalid-feedback">
Please provide a title for your activity.
</div>
</div>
<!-- Activity Type -->
<div class="mb-3">
<label for="activityType" class="form-label">
Activity Type <span class="text-danger">*</span>
</label>
<select class="form-select" id="activityType" name="activityType" required>
<option value="RUN">Run</option>
<option value="RIDE">Ride</option>
<option value="HIKE">Hike</option>
<option value="WALK">Walk</option>
<option value="SWIM">Swim</option>
<option value="OTHER">Other</option>
</select>
</div>
<!-- Description -->
<div class="mb-3">
<label for="description" class="form-label">
Description
</label>
<textarea class="form-control"
id="description"
name="description"
rows="4"
placeholder="Share details about your activity..."
maxlength="5000"></textarea>
<div class="form-text">
<span id="descCharCount">0</span>/5000 characters
</div>
</div>
<!-- Visibility -->
<div class="mb-4">
<label for="visibility" class="form-label">
Visibility <span class="text-danger">*</span>
</label>
<select class="form-select" id="visibility" name="visibility" required>
<option value="PUBLIC">Public - Anyone can see</option>
<option value="FOLLOWERS">Followers Only - Only your followers can see</option>
<option value="PRIVATE">Private - Only you can see</option>
</select>
<div class="form-text">
<i class="bi bi-info-circle"></i>
Public activities will be shared on the Fediverse
</div>
</div>
<!-- Activity Summary (Read-only) -->
<div class="alert alert-info">
<h6><i class="bi bi-info-circle"></i> Activity Summary</h6>
<div id="summaryContent">
<!-- Will be populated by JavaScript -->
</div>
</div>
<!-- Submit Buttons -->
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<a th:href="@{/activities}" class="btn btn-outline-secondary" id="cancelBtn">
<i class="bi bi-x-circle"></i> Cancel
</a>
<button type="submit" class="btn btn-primary" id="saveBtn">
<span id="saveBtnText">
<i class="bi bi-check-circle"></i> Save Changes
</span>
<span id="saveBtnSpinner" class="d-none">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Saving...
</span>
</button>
</div>
</form>
</div>
</div>
<!-- Activity Preview Map (Optional) -->
<div class="card mt-4 d-none" id="mapPreview">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-map"></i> Route Preview
</h5>
</div>
<div class="card-body p-0">
<div id="activityMap" class="map-container-large"></div>
</div>
</div>
</div>
</div>
</div>
<!-- Custom Scripts -->
<th:block layout:fragment="scripts">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
const activityId = window.location.pathname.split('/').slice(-2, -1)[0];
const form = document.getElementById('editForm');
const loadingIndicator = document.getElementById('loadingIndicator');
const editCard = document.getElementById('editCard');
const errorAlert = document.getElementById('errorAlert');
const successAlert = document.getElementById('successAlert');
const errorMessage = document.getElementById('errorMessage');
const saveBtn = document.getElementById('saveBtn');
const saveBtnText = document.getElementById('saveBtnText');
const saveBtnSpinner = document.getElementById('saveBtnSpinner');
const descriptionInput = document.getElementById('description');
const descCharCount = document.getElementById('descCharCount');
const cancelBtn = document.getElementById('cancelBtn');
// Load activity data
loadActivity();
async function loadActivity() {
try {
const response = await FitPubAuth.authenticatedFetch(`/api/activities/${activityId}`);
if (response.ok) {
const activity = await response.json();
populateForm(activity);
// Hide loading, show form
loadingIndicator.classList.add('d-none');
editCard.classList.remove('d-none');
// Show map if track exists
if (activity.simplifiedTrack) {
document.getElementById('mapPreview').classList.remove('d-none');
renderMap(activity.simplifiedTrack);
}
} else {
throw new Error('Failed to load activity');
}
} catch (error) {
console.error('Error loading activity:', error);
loadingIndicator.classList.add('d-none');
errorMessage.textContent = 'Failed to load activity. Please try again.';
errorAlert.classList.remove('d-none');
}
}
function populateForm(activity) {
// Populate form fields
document.getElementById('title').value = activity.title || '';
document.getElementById('activityType').value = activity.activityType || 'OTHER';
document.getElementById('description').value = activity.description || '';
document.getElementById('visibility').value = activity.visibility || 'PUBLIC';
// Update character count
descCharCount.textContent = (activity.description || '').length;
// Populate summary
document.getElementById('summaryContent').innerHTML = `
<p class="mb-1"><strong>Date:</strong> ${new Date(activity.startedAt).toLocaleString()}</p>
<p class="mb-1"><strong>Distance:</strong> ${formatDistance(activity.totalDistance)}</p>
<p class="mb-1"><strong>Duration:</strong> ${formatDuration(activity.totalDuration)}</p>
<p class="mb-0"><strong>Elevation Gain:</strong> ${activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A'}</p>
`;
// Update cancel button to go back to activity detail
cancelBtn.href = `/activities/${activityId}`;
}
function renderMap(simplifiedTrack) {
const geoJson = {
type: 'LineString',
coordinates: simplifiedTrack.coordinates
};
FitPub.createActivityMap('activityMap', geoJson, {
showStartEnd: true,
fitBounds: true
});
}
// Description character count
descriptionInput.addEventListener('input', function() {
descCharCount.textContent = this.value.length;
});
// Form submission
form.addEventListener('submit', async function(e) {
e.preventDefault();
// Validate form
if (!form.checkValidity()) {
form.classList.add('was-validated');
return;
}
// Hide alerts
errorAlert.classList.add('d-none');
successAlert.classList.add('d-none');
// Show loading state
saveBtn.disabled = true;
saveBtnText.classList.add('d-none');
saveBtnSpinner.classList.remove('d-none');
// Prepare update data
const updateData = {
title: document.getElementById('title').value,
activityType: document.getElementById('activityType').value,
description: document.getElementById('description').value,
visibility: document.getElementById('visibility').value
};
try {
const response = await FitPubAuth.authenticatedFetch(
`/api/activities/${activityId}`,
{
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(updateData)
}
);
if (response.ok) {
// Show success
successAlert.classList.remove('d-none');
// Redirect to activity detail page
setTimeout(() => {
window.location.href = `/activities/${activityId}`;
}, 1500);
} else {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to update activity');
}
} catch (error) {
console.error('Update error:', error);
errorMessage.textContent = error.message || 'An error occurred while updating. Please try again.';
errorAlert.classList.remove('d-none');
// Reset button state
saveBtn.disabled = false;
saveBtnText.classList.remove('d-none');
saveBtnSpinner.classList.add('d-none');
// Scroll to error
errorAlert.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
// Helper functions
function formatDistance(meters) {
if (!meters) return 'N/A';
if (meters >= 1000) {
return (meters / 1000).toFixed(2) + ' 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);
const secs = Math.floor(seconds % 60);
const parts = [];
if (hours > 0) parts.push(hours + 'h');
if (minutes > 0) parts.push(minutes + 'm');
if (secs > 0 || parts.length === 0) parts.push(secs + 's');
return parts.join(' ');
}
});
</script>
</th:block>
</body>
</html>

View file

@ -0,0 +1,339 @@
<!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 Activities</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-list-task text-primary"></i>
My Activities
</h2>
<a th:href="@{/activities/upload}" class="btn btn-primary">
<i class="bi bi-cloud-upload"></i> Upload Activity
</a>
</div>
<!-- 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 your activities...</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>
<!-- Activities List -->
<div id="activitiesList" 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="Activities pagination" class="mt-4 d-none">
<ul class="pagination justify-content-center" id="paginationList">
<!-- Will be populated by JavaScript -->
</ul>
</nav>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div class="modal fade" id="deleteModal" tabindex="-1" aria-labelledby="deleteModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="deleteModalLabel">
<i class="bi bi-exclamation-triangle text-danger"></i>
Delete Activity
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete this activity?</p>
<p class="text-danger mb-0"><strong>This action cannot be undone.</strong></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Cancel</button>
<button type="button" class="btn btn-danger" id="confirmDeleteBtn">
<i class="bi bi-trash"></i> Delete
</button>
</div>
</div>
</div>
</div>
</div>
<!-- Custom Scripts -->
<th:block layout:fragment="scripts">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
const loadingIndicator = document.getElementById('loadingIndicator');
const errorAlert = document.getElementById('errorAlert');
const errorMessage = document.getElementById('errorMessage');
const activitiesList = document.getElementById('activitiesList');
const emptyState = document.getElementById('emptyState');
const pagination = document.getElementById('pagination');
let currentPage = 0;
let totalPages = 0;
let activityToDelete = null;
// Load activities
loadActivities(currentPage);
async function loadActivities(page) {
try {
// Show loading
loadingIndicator.classList.remove('d-none');
activitiesList.classList.add('d-none');
emptyState.classList.add('d-none');
errorAlert.classList.add('d-none');
pagination.classList.add('d-none');
const response = await FitPubAuth.authenticatedFetch(
`/api/activities?page=${page}&size=10&sort=startedAt,desc`
);
if (response.ok) {
const data = await response.json();
// Hide loading
loadingIndicator.classList.add('d-none');
if (data.content && data.content.length > 0) {
renderActivities(data.content);
renderPagination(data);
activitiesList.classList.remove('d-none');
pagination.classList.remove('d-none');
} else {
emptyState.classList.remove('d-none');
}
totalPages = data.totalPages;
currentPage = data.number;
} else {
throw new Error('Failed to load activities');
}
} catch (error) {
console.error('Error loading activities:', error);
loadingIndicator.classList.add('d-none');
errorMessage.textContent = 'Failed to load activities. Please try again.';
errorAlert.classList.remove('d-none');
}
}
function renderActivities(activities) {
activitiesList.innerHTML = activities.map(activity => `
<div class="card activity-card mb-3">
<div class="card-body">
<div class="row">
<div class="col-md-8">
<h5 class="card-title">
<a href="/activities/${activity.id}" class="text-decoration-none">
${escapeHtml(activity.title || 'Untitled Activity')}
</a>
</h5>
<p class="text-muted 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>
<span class="ms-2 visibility-${activity.visibility.toLowerCase()}">
<i class="bi bi-${getVisibilityIcon(activity.visibility)}"></i>
${activity.visibility}
</span>
</p>
${activity.description ? `<p class="card-text">${escapeHtml(activity.description).substring(0, 150)}${activity.description.length > 150 ? '...' : ''}</p>` : ''}
</div>
<div class="col-md-4">
<div class="row text-center">
<div class="col-4">
<div class="metric-card">
<div class="metric-value small">${formatDistance(activity.totalDistance)}</div>
<div class="metric-label">Distance</div>
</div>
</div>
<div class="col-4">
<div class="metric-card">
<div class="metric-value small">${formatDuration(activity.totalDuration)}</div>
<div class="metric-label">Time</div>
</div>
</div>
<div class="col-4">
<div class="metric-card">
<div class="metric-value small">${activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A'}</div>
<div class="metric-label">Elevation</div>
</div>
</div>
</div>
</div>
</div>
<div class="mt-3 d-flex gap-2">
<a href="/activities/${activity.id}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> View
</a>
<a href="/activities/${activity.id}/edit" class="btn btn-sm btn-outline-secondary">
<i class="bi bi-pencil"></i> Edit
</a>
<button class="btn btn-sm btn-outline-danger" onclick="confirmDelete('${activity.id}')">
<i class="bi bi-trash"></i> Delete
</button>
</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);
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="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="changePage(${data.number + 1}); return false;">
<i class="bi bi-chevron-right"></i>
</a>
</li>
`;
paginationList.innerHTML = html;
}
// Global functions for pagination and delete
window.changePage = function(page) {
loadActivities(page);
window.scrollTo({ top: 0, behavior: 'smooth' });
};
window.confirmDelete = function(activityId) {
activityToDelete = activityId;
const modal = new bootstrap.Modal(document.getElementById('deleteModal'));
modal.show();
};
// Delete confirmation
document.getElementById('confirmDeleteBtn').addEventListener('click', async function() {
if (!activityToDelete) return;
try {
const response = await FitPubAuth.authenticatedFetch(
`/api/activities/${activityToDelete}`,
{ method: 'DELETE' }
);
if (response.ok) {
// Close modal
const modal = bootstrap.Modal.getInstance(document.getElementById('deleteModal'));
modal.hide();
// Reload activities
loadActivities(currentPage);
// Show success message
FitPub.showAlert('Activity deleted successfully', 'success');
} else {
throw new Error('Failed to delete activity');
}
} catch (error) {
console.error('Delete error:', error);
FitPub.showAlert('Failed to delete activity. Please try again.', 'danger');
}
activityToDelete = null;
});
// Helper functions
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 getVisibilityIcon(visibility) {
switch (visibility) {
case 'PUBLIC': return 'globe';
case 'FOLLOWERS': return 'people';
case 'PRIVATE': return 'lock';
default: return 'question-circle';
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
});
</script>
</th:block>
</body>
</html>

View file

@ -0,0 +1,420 @@
<!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>Upload Activity</title>
</head>
<body>
<div layout:fragment="content">
<div class="row justify-content-center">
<div class="col-lg-8">
<h2 class="mb-4">
<i class="bi bi-cloud-upload text-primary"></i>
Upload Activity
</h2>
<!-- Success Alert -->
<div id="successAlert" class="alert alert-success d-none" role="alert">
<i class="bi bi-check-circle-fill"></i>
<span id="successMessage">Activity uploaded successfully!</span>
<a href="#" id="viewActivityLink" class="alert-link">View activity</a>
</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>
<!-- Upload Form -->
<div class="card shadow-sm" id="uploadCard">
<div class="card-body p-4">
<form id="uploadForm" enctype="multipart/form-data">
<!-- File Upload Area -->
<div class="mb-4">
<label class="form-label fw-bold">
FIT File <span class="text-danger">*</span>
</label>
<div class="file-upload-area" id="fileUploadArea">
<input type="file"
id="fitFile"
name="file"
accept=".fit"
class="d-none"
required>
<div class="file-upload-icon">
<i class="bi bi-cloud-arrow-up"></i>
</div>
<p class="mb-2"><strong>Drop your FIT file here</strong></p>
<p class="text-muted mb-2">or click to browse</p>
<p class="file-upload-label text-primary fw-bold" id="fileLabel">
No file selected
</p>
<small class="text-muted">Supported: .fit files from Garmin, Wahoo, etc. (Max 50MB)</small>
</div>
<div class="invalid-feedback">
Please select a FIT file to upload.
</div>
</div>
<!-- Progress Bar -->
<div class="mb-4 d-none" id="progressContainer">
<label class="form-label">Upload Progress</label>
<div class="progress" style="height: 25px;">
<div class="progress-bar progress-bar-striped progress-bar-animated"
id="progressBar"
role="progressbar"
style="width: 0%"
aria-valuenow="0"
aria-valuemin="0"
aria-valuemax="100">
<span id="progressText">0%</span>
</div>
</div>
<small class="text-muted" id="progressStatus">Uploading...</small>
</div>
<!-- Activity Metadata (shown after upload) -->
<div id="metadataSection" class="d-none">
<hr class="my-4">
<h5 class="mb-3">Activity Details</h5>
<!-- Title -->
<div class="mb-3">
<label for="title" class="form-label">
Title <span class="text-danger">*</span>
</label>
<input type="text"
class="form-control"
id="title"
name="title"
placeholder="e.g., Morning Run"
maxlength="200">
<div class="invalid-feedback">
Please provide a title for your activity.
</div>
</div>
<!-- Description -->
<div class="mb-3">
<label for="description" class="form-label">
Description
</label>
<textarea class="form-control"
id="description"
name="description"
rows="4"
placeholder="Share details about your activity..."
maxlength="5000"></textarea>
<div class="form-text">
<span id="descCharCount">0</span>/5000 characters
</div>
</div>
<!-- Visibility -->
<div class="mb-4">
<label for="visibility" class="form-label">
Visibility <span class="text-danger">*</span>
</label>
<select class="form-select" id="visibility" name="visibility">
<option value="PUBLIC" selected>Public - Anyone can see</option>
<option value="FOLLOWERS">Followers Only - Only your followers can see</option>
<option value="PRIVATE">Private - Only you can see</option>
</select>
<div class="form-text">
<i class="bi bi-info-circle"></i>
Public activities will be shared on the Fediverse
</div>
</div>
<!-- Activity Summary (from uploaded file) -->
<div id="activitySummary" class="alert alert-info">
<h6><i class="bi bi-info-circle"></i> Activity Summary</h6>
<div id="summaryContent">
<!-- Will be populated by JavaScript -->
</div>
</div>
</div>
<!-- Submit Buttons -->
<div class="d-grid gap-2 d-md-flex justify-content-md-end">
<button type="button" class="btn btn-outline-secondary" id="cancelBtn">
<i class="bi bi-x-circle"></i> Cancel
</button>
<button type="submit" class="btn btn-primary" id="uploadBtn">
<span id="uploadBtnText">
<i class="bi bi-cloud-upload"></i> Upload Activity
</span>
<span id="uploadBtnSpinner" class="d-none">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Processing...
</span>
</button>
</div>
</form>
</div>
</div>
<!-- Upload Tips -->
<div class="card border-0 bg-light mt-4">
<div class="card-body">
<h6><i class="bi bi-lightbulb text-warning"></i> Upload Tips</h6>
<ul class="mb-0 small">
<li>FIT files can be exported from Garmin Connect, Strava, Wahoo, and most GPS devices</li>
<li>The activity will be processed to extract GPS tracks, metrics, and statistics</li>
<li>You can add a title and description after uploading</li>
<li>Public activities will appear in your followers' timelines on the Fediverse</li>
</ul>
</div>
</div>
</div>
</div>
</div>
<!-- Custom Scripts -->
<th:block layout:fragment="scripts">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('uploadForm');
const fitFileInput = document.getElementById('fitFile');
const fileLabel = document.getElementById('fileLabel');
const uploadBtn = document.getElementById('uploadBtn');
const uploadBtnText = document.getElementById('uploadBtnText');
const uploadBtnSpinner = document.getElementById('uploadBtnSpinner');
const cancelBtn = document.getElementById('cancelBtn');
const errorAlert = document.getElementById('errorAlert');
const successAlert = document.getElementById('successAlert');
const errorMessage = document.getElementById('errorMessage');
const progressContainer = document.getElementById('progressContainer');
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const progressStatus = document.getElementById('progressStatus');
const metadataSection = document.getElementById('metadataSection');
const descriptionInput = document.getElementById('description');
const descCharCount = document.getElementById('descCharCount');
let uploadedActivityId = null;
// File selection handler
fitFileInput.addEventListener('change', function() {
if (this.files.length > 0) {
const file = this.files[0];
fileLabel.textContent = file.name;
fileLabel.classList.add('text-success');
}
});
// Description character count
if (descriptionInput) {
descriptionInput.addEventListener('input', function() {
descCharCount.textContent = this.value.length;
});
}
// Cancel button
cancelBtn.addEventListener('click', function() {
if (confirm('Are you sure you want to cancel? Any unsaved changes will be lost.')) {
window.location.href = '/activities';
}
});
// Form submission
form.addEventListener('submit', async function(e) {
e.preventDefault();
console.log('Form submitted');
console.log('File input files:', fitFileInput.files);
// Validate form
if (!form.checkValidity()) {
console.log('Form validation failed');
form.classList.add('was-validated');
return;
}
// Check if file is selected
if (!fitFileInput.files || fitFileInput.files.length === 0) {
console.error('No file selected');
errorMessage.textContent = 'Please select a FIT file to upload.';
errorAlert.classList.remove('d-none');
return;
}
console.log('Starting upload...');
// Hide alerts
errorAlert.classList.add('d-none');
successAlert.classList.add('d-none');
// Show progress
progressContainer.classList.remove('d-none');
uploadBtn.disabled = true;
cancelBtn.disabled = true;
uploadBtnText.classList.add('d-none');
uploadBtnSpinner.classList.remove('d-none');
// Prepare form data
const formData = new FormData();
formData.append('file', fitFileInput.files[0]);
// If metadata is filled, include it
if (!metadataSection.classList.contains('d-none')) {
formData.append('title', document.getElementById('title').value);
formData.append('description', document.getElementById('description').value);
formData.append('visibility', document.getElementById('visibility').value);
}
try {
// Upload with progress tracking
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', function(e) {
if (e.lengthComputable) {
const percentComplete = Math.round((e.loaded / e.total) * 100);
progressBar.style.width = percentComplete + '%';
progressBar.setAttribute('aria-valuenow', percentComplete);
progressText.textContent = percentComplete + '%';
}
});
xhr.addEventListener('load', function() {
console.log('XHR load event, status:', xhr.status);
console.log('Response:', xhr.responseText);
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.responseText);
uploadedActivityId = response.id;
progressStatus.textContent = 'Processing complete!';
progressBar.classList.remove('progress-bar-animated');
progressBar.classList.add('bg-success');
// Show success message
successAlert.classList.remove('d-none');
document.getElementById('viewActivityLink').href = '/activities/' + uploadedActivityId;
// Show metadata section if not already shown
if (metadataSection.classList.contains('d-none')) {
metadataSection.classList.remove('d-none');
// Populate summary
document.getElementById('summaryContent').innerHTML = `
<p class="mb-1"><strong>Type:</strong> ${response.activityType || 'Unknown'}</p>
<p class="mb-1"><strong>Distance:</strong> ${formatDistance(response.totalDistance)}</p>
<p class="mb-1"><strong>Duration:</strong> ${formatDuration(response.totalDurationSeconds)}</p>
<p class="mb-0"><strong>Date:</strong> ${new Date(response.startedAt).toLocaleString()}</p>
`;
// Pre-fill title with activity type and date
const activityDate = new Date(response.startedAt);
document.getElementById('title').value = `${response.activityType || 'Activity'} - ${activityDate.toLocaleDateString()}`;
}
// Reset form state
uploadBtn.disabled = false;
cancelBtn.disabled = false;
uploadBtnText.classList.remove('d-none');
uploadBtnSpinner.classList.add('d-none');
uploadBtn.innerHTML = '<i class="bi bi-check-circle"></i> Save Details';
} catch (parseError) {
console.error('Error parsing response:', parseError);
errorMessage.textContent = 'Error processing server response: ' + parseError.message;
errorAlert.classList.remove('d-none');
// Reset button state
uploadBtn.disabled = false;
cancelBtn.disabled = false;
uploadBtnText.classList.remove('d-none');
uploadBtnSpinner.classList.add('d-none');
progressContainer.classList.add('d-none');
}
} else {
console.error('Upload failed with status:', xhr.status);
let errorMsg = 'Upload failed';
try {
const errorData = JSON.parse(xhr.responseText);
errorMsg = errorData.message || errorMsg;
} catch (e) {
errorMsg = xhr.responseText || errorMsg;
}
errorMessage.textContent = errorMsg;
errorAlert.classList.remove('d-none');
// Reset button state
uploadBtn.disabled = false;
cancelBtn.disabled = false;
uploadBtnText.classList.remove('d-none');
uploadBtnSpinner.classList.add('d-none');
progressContainer.classList.add('d-none');
}
});
xhr.addEventListener('error', function() {
console.error('XHR error event');
errorMessage.textContent = 'Network error during upload';
errorAlert.classList.remove('d-none');
// Reset button state
uploadBtn.disabled = false;
cancelBtn.disabled = false;
uploadBtnText.classList.remove('d-none');
uploadBtnSpinner.classList.add('d-none');
progressContainer.classList.add('d-none');
});
// Add JWT token
const token = localStorage.getItem('jwtToken');
if (token) {
xhr.open('POST', '/api/activities/upload');
xhr.setRequestHeader('Authorization', 'Bearer ' + token);
xhr.send(formData);
} else {
throw new Error('Not authenticated. Please login first.');
}
} catch (error) {
console.error('Upload error:', error);
errorMessage.textContent = error.message || 'An error occurred during upload. Please try again.';
errorAlert.classList.remove('d-none');
// Reset button state
uploadBtn.disabled = false;
cancelBtn.disabled = false;
uploadBtnText.classList.remove('d-none');
uploadBtnSpinner.classList.add('d-none');
progressContainer.classList.add('d-none');
}
});
// Helper functions
function formatDistance(meters) {
if (!meters) return 'N/A';
if (meters >= 1000) {
return (meters / 1000).toFixed(2) + ' 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);
const secs = Math.floor(seconds % 60);
const parts = [];
if (hours > 0) parts.push(hours + 'h');
if (minutes > 0) parts.push(minutes + 'm');
if (secs > 0 || parts.length === 0) parts.push(secs + 's');
return parts.join(' ');
}
});
</script>
</th:block>
</body>
</html>

View file

@ -0,0 +1,199 @@
<!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>Login</title>
</head>
<body>
<div layout:fragment="content">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-4">
<div class="card shadow-sm">
<div class="card-body p-5">
<h2 class="text-center mb-4">
<i class="bi bi-box-arrow-in-right text-primary"></i>
Sign In
</h2>
<p class="text-muted text-center mb-4">
Welcome back to FitPub
</p>
<!-- Login Form -->
<form id="loginForm">
<!-- 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>
<!-- Username or Email -->
<div class="mb-3">
<label for="usernameOrEmail" class="form-label">
Username or Email
</label>
<input type="text"
class="form-control form-control-lg"
id="usernameOrEmail"
name="usernameOrEmail"
placeholder="Enter username or email"
required
autocomplete="username">
<div class="invalid-feedback">
Please enter your username or email.
</div>
</div>
<!-- Password -->
<div class="mb-4">
<label for="password" class="form-label">
Password
</label>
<input type="password"
class="form-control form-control-lg"
id="password"
name="password"
placeholder="Enter password"
required
autocomplete="current-password">
<div class="invalid-feedback">
Please enter your password.
</div>
</div>
<!-- Remember Me (Optional for future) -->
<div class="mb-3 form-check">
<input type="checkbox"
class="form-check-input"
id="rememberMe"
name="rememberMe">
<label class="form-check-label" for="rememberMe">
Remember me
</label>
</div>
<!-- Submit Button -->
<div class="d-grid mb-3">
<button type="submit" class="btn btn-primary btn-lg" id="loginBtn">
<span id="loginBtnText">
<i class="bi bi-box-arrow-in-right"></i> Sign In
</span>
<span id="loginBtnSpinner" class="d-none">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Signing in...
</span>
</button>
</div>
</form>
<!-- Divider -->
<div class="text-center my-3">
<hr>
</div>
<!-- Register Link -->
<div class="text-center">
<p class="text-muted mb-0">
Don't have an account?
<a th:href="@{/register}" class="text-decoration-none fw-bold">Create one</a>
</p>
</div>
</div>
</div>
<!-- Help Box -->
<div class="card border-0 bg-light mt-4">
<div class="card-body">
<h6><i class="bi bi-question-circle text-primary"></i> Need Help?</h6>
<p class="text-muted small mb-0">
Forgot your password? Contact your instance administrator or create a new account.
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Custom Scripts -->
<th:block layout:fragment="scripts">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('loginForm');
const loginBtn = document.getElementById('loginBtn');
const loginBtnText = document.getElementById('loginBtnText');
const loginBtnSpinner = document.getElementById('loginBtnSpinner');
const errorAlert = document.getElementById('errorAlert');
const errorMessage = document.getElementById('errorMessage');
// Form submission
form.addEventListener('submit', async function(e) {
e.preventDefault();
// Validate form
if (!form.checkValidity()) {
form.classList.add('was-validated');
return;
}
// Hide error alert
errorAlert.classList.add('d-none');
// Show loading state
loginBtn.disabled = true;
loginBtnText.classList.add('d-none');
loginBtnSpinner.classList.remove('d-none');
// Prepare request data
const formData = {
usernameOrEmail: document.getElementById('usernameOrEmail').value,
password: document.getElementById('password').value
};
try {
const response = await fetch('/api/auth/login', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData)
});
const data = await response.json();
if (response.ok) {
// Store JWT token
localStorage.setItem('jwtToken', data.token);
localStorage.setItem('username', data.username);
// Redirect to activities page
window.location.href = '/activities';
} else {
// Show error message
errorMessage.textContent = data.message || 'Invalid username/email or password.';
errorAlert.classList.remove('d-none');
// Reset button state
loginBtn.disabled = false;
loginBtnText.classList.remove('d-none');
loginBtnSpinner.classList.add('d-none');
}
} catch (error) {
console.error('Login error:', error);
errorMessage.textContent = 'An unexpected error occurred. Please try again.';
errorAlert.classList.remove('d-none');
// Reset button state
loginBtn.disabled = false;
loginBtnText.classList.remove('d-none');
loginBtnSpinner.classList.add('d-none');
}
});
});
</script>
</th:block>
</body>
</html>

View file

@ -0,0 +1,273 @@
<!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>Register</title>
</head>
<body>
<div layout:fragment="content">
<div class="row justify-content-center">
<div class="col-md-6 col-lg-5">
<div class="card shadow-sm">
<div class="card-body p-5">
<h2 class="text-center mb-4">
<i class="bi bi-person-plus-fill text-primary"></i>
Create Account
</h2>
<p class="text-muted text-center mb-4">
Join the federated fitness community
</p>
<!-- Registration Form -->
<form id="registerForm">
<!-- 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>
Registration successful! Redirecting to login...
</div>
<!-- Username -->
<div class="mb-3">
<label for="username" class="form-label">
Username <span class="text-danger">*</span>
</label>
<input type="text"
class="form-control"
id="username"
name="username"
placeholder="Choose a username"
required
minlength="3"
maxlength="30"
pattern="[a-zA-Z0-9_]+"
autocomplete="username">
<div class="form-text">
3-30 characters. Letters, numbers, and underscores only.
</div>
<div class="invalid-feedback">
Please provide a valid username.
</div>
</div>
<!-- Email -->
<div class="mb-3">
<label for="email" class="form-label">
Email <span class="text-danger">*</span>
</label>
<input type="email"
class="form-control"
id="email"
name="email"
placeholder="your@email.com"
required
autocomplete="email">
<div class="invalid-feedback">
Please provide a valid email address.
</div>
</div>
<!-- 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"
placeholder="Your name (optional)"
maxlength="100"
autocomplete="name">
<div class="form-text">
This is how your name will appear to others.
</div>
</div>
<!-- Password -->
<div class="mb-3">
<label for="password" class="form-label">
Password <span class="text-danger">*</span>
</label>
<input type="password"
class="form-control"
id="password"
name="password"
placeholder="Create a strong password"
required
minlength="8"
autocomplete="new-password">
<div class="form-text">
At least 8 characters.
</div>
<div class="invalid-feedback">
Password must be at least 8 characters.
</div>
</div>
<!-- Confirm Password -->
<div class="mb-4">
<label for="confirmPassword" class="form-label">
Confirm Password <span class="text-danger">*</span>
</label>
<input type="password"
class="form-control"
id="confirmPassword"
name="confirmPassword"
placeholder="Confirm your password"
required
minlength="8"
autocomplete="new-password">
<div class="invalid-feedback">
Passwords do not match.
</div>
</div>
<!-- Submit Button -->
<div class="d-grid mb-3">
<button type="submit" class="btn btn-primary btn-lg" id="registerBtn">
<span id="registerBtnText">
<i class="bi bi-person-plus"></i> Create Account
</span>
<span id="registerBtnSpinner" class="d-none">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Creating account...
</span>
</button>
</div>
</form>
<!-- Login Link -->
<div class="text-center">
<p class="text-muted mb-0">
Already have an account?
<a th:href="@{/login}" class="text-decoration-none">Sign in</a>
</p>
</div>
</div>
</div>
<!-- Info Box -->
<div class="card border-0 bg-light mt-4">
<div class="card-body">
<h6><i class="bi bi-info-circle text-primary"></i> About FitPub</h6>
<p class="text-muted small mb-0">
FitPub is a federated fitness tracking platform. Your account can interact with
users on Mastodon, Pleroma, and other ActivityPub-compatible platforms.
</p>
</div>
</div>
</div>
</div>
</div>
<!-- Custom Scripts -->
<th:block layout:fragment="scripts">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('registerForm');
const registerBtn = document.getElementById('registerBtn');
const registerBtnText = document.getElementById('registerBtnText');
const registerBtnSpinner = document.getElementById('registerBtnSpinner');
const errorAlert = document.getElementById('errorAlert');
const successAlert = document.getElementById('successAlert');
const errorMessage = document.getElementById('errorMessage');
// Password confirmation validation
const password = document.getElementById('password');
const confirmPassword = document.getElementById('confirmPassword');
confirmPassword.addEventListener('input', function() {
if (password.value !== confirmPassword.value) {
confirmPassword.setCustomValidity('Passwords do not match');
} else {
confirmPassword.setCustomValidity('');
}
});
// Form submission
form.addEventListener('submit', async function(e) {
e.preventDefault();
// Validate form
if (!form.checkValidity()) {
form.classList.add('was-validated');
return;
}
// Hide alerts
errorAlert.classList.add('d-none');
successAlert.classList.add('d-none');
// Show loading state
registerBtn.disabled = true;
registerBtnText.classList.add('d-none');
registerBtnSpinner.classList.remove('d-none');
// Prepare request data
const formData = {
username: document.getElementById('username').value,
email: document.getElementById('email').value,
password: document.getElementById('password').value,
displayName: document.getElementById('displayName').value || null
};
try {
const response = await fetch('/api/auth/register', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(formData)
});
const data = await response.json();
if (response.ok) {
// Store JWT token
localStorage.setItem('jwtToken', data.token);
localStorage.setItem('username', data.username);
// Show success message
successAlert.classList.remove('d-none');
// Redirect to activities page after 1.5 seconds
setTimeout(() => {
window.location.href = '/activities';
}, 1500);
} else {
// Show error message
errorMessage.textContent = data.message || 'Registration failed. Please try again.';
errorAlert.classList.remove('d-none');
// Reset button state
registerBtn.disabled = false;
registerBtnText.classList.remove('d-none');
registerBtnSpinner.classList.add('d-none');
}
} catch (error) {
console.error('Registration error:', error);
errorMessage.textContent = 'An unexpected error occurred. Please try again.';
errorAlert.classList.remove('d-none');
// Reset button state
registerBtn.disabled = false;
registerBtnText.classList.remove('d-none');
registerBtnSpinner.classList.add('d-none');
}
});
});
</script>
</th:block>
</body>
</html>

View file

@ -0,0 +1,32 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FitPub - Federated Fitness Tracking</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
</head>
<body>
<div class="container mt-5">
<h1 class="text-center">
<i class="bi bi-activity text-primary"></i>
FitPub
</h1>
<p class="lead text-center">Federated Fitness Tracking</p>
<div class="text-center mt-4">
<a th:href="@{/register}" class="btn btn-primary me-2">
<i class="bi bi-person-plus"></i> Register
</a>
<a th:href="@{/login}" class="btn btn-outline-primary">
<i class="bi bi-box-arrow-in-right"></i> Login
</a>
</div>
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
</body>
</html>

View file

@ -0,0 +1,139 @@
<!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>Home - FitPub</title>
</head>
<body>
<div layout:fragment="content">
<!-- Hero Section -->
<div class="row mb-5">
<div class="col-lg-12 text-center">
<h1 class="display-4 fw-bold mb-3">
<i class="bi bi-activity text-primary"></i>
FitPub
</h1>
<p class="lead text-muted mb-4">
Federated Fitness Tracking for the Fediverse
</p>
<p class="mb-4">
Share your fitness activities with followers on Mastodon, Pleroma, and other ActivityPub platforms.
Upload FIT files from your GPS devices and track your progress.
</p>
<div class="d-grid gap-2 d-sm-flex justify-content-sm-center">
<a th:href="@{/register}" class="btn btn-primary btn-lg px-4 gap-3">
<i class="bi bi-person-plus"></i> Get Started
</a>
<a th:href="@{/timeline}" class="btn btn-outline-secondary btn-lg px-4">
<i class="bi bi-globe"></i> View Public Timeline
</a>
</div>
</div>
</div>
<!-- Features Section -->
<div class="row g-4 mb-5">
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center p-4">
<div class="mb-3">
<i class="bi bi-map text-primary" style="font-size: 3rem;"></i>
</div>
<h5 class="card-title">Interactive Maps</h5>
<p class="card-text text-muted">
View your GPS tracks on interactive maps with elevation profiles and detailed metrics.
</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center p-4">
<div class="mb-3">
<i class="bi bi-share text-primary" style="font-size: 3rem;"></i>
</div>
<h5 class="card-title">Federated Sharing</h5>
<p class="card-text text-muted">
Share activities with followers across the Fediverse using the ActivityPub protocol.
</p>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100 border-0 shadow-sm">
<div class="card-body text-center p-4">
<div class="mb-3">
<i class="bi bi-shield-check text-primary" style="font-size: 3rem;"></i>
</div>
<h5 class="card-title">Privacy Control</h5>
<p class="card-text text-muted">
Choose who sees your activities: public, followers-only, or private.
</p>
</div>
</div>
</div>
</div>
<!-- How It Works Section -->
<div class="row mb-5">
<div class="col-lg-8 mx-auto">
<h2 class="text-center mb-4">How It Works</h2>
<div class="timeline-item">
<h5 class="timeline-date">Step 1</h5>
<h6>Upload Your FIT File</h6>
<p class="text-muted">
Export a FIT file from your GPS device (Garmin, Wahoo, etc.) and upload it to FitPub.
</p>
</div>
<div class="timeline-item">
<h5 class="timeline-date">Step 2</h5>
<h6>View Your Activity</h6>
<p class="text-muted">
See your GPS track on an interactive map with detailed metrics like distance, pace, elevation, and heart rate.
</p>
</div>
<div class="timeline-item">
<h5 class="timeline-date">Step 3</h5>
<h6>Share on the Fediverse</h6>
<p class="text-muted">
Your activity appears in your followers' timelines on Mastodon, Pleroma, and other ActivityPub platforms.
</p>
</div>
<div class="timeline-item">
<h5 class="timeline-date">Step 4</h5>
<h6>Follow Other Athletes</h6>
<p class="text-muted">
Connect with other athletes on the Fediverse and see their public workouts in your timeline.
</p>
</div>
</div>
</div>
<!-- CTA Section -->
<div class="row">
<div class="col-lg-8 mx-auto text-center">
<div class="bg-light p-5 rounded">
<h3 class="mb-3">Ready to Join the Federated Fitness Community?</h3>
<p class="text-muted mb-4">
Own your fitness data. Share on your terms. Connect with athletes across the Fediverse.
</p>
<a th:href="@{/register}" class="btn btn-primary btn-lg">
<i class="bi bi-person-plus"></i> Create Your Account
</a>
</div>
</div>
</div>
</div>
</body>
</html>

View file

@ -0,0 +1,210 @@
<!DOCTYPE html>
<html lang="en" xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="IE=edge">
<title th:text="${pageTitle != null ? pageTitle + ' - FitPub' : 'FitPub - Federated Fitness Tracking'}">FitPub</title>
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css"
rel="stylesheet"
integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN"
crossorigin="anonymous">
<!-- Bootstrap Icons -->
<link rel="stylesheet"
href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.1/font/bootstrap-icons.css">
<!-- Leaflet CSS -->
<link rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin="anonymous"/>
<!-- Custom CSS -->
<link rel="stylesheet" th:href="@{/css/fitpub.css}">
<!-- Additional head content from child templates -->
<th:block layout:fragment="head"></th:block>
</head>
<body>
<!-- Navigation -->
<nav class="navbar navbar-expand-lg navbar-dark bg-dark">
<div class="container-fluid">
<a class="navbar-brand" th:href="@{/}">
<i class="bi bi-activity"></i> FitPub
</a>
<button class="navbar-toggler"
type="button"
data-bs-toggle="collapse"
data-bs-target="#navbarNav"
aria-controls="navbarNav"
aria-expanded="false"
aria-label="Toggle navigation">
<span class="navbar-toggler-icon"></span>
</button>
<div class="collapse navbar-collapse" id="navbarNav">
<!-- Left side navigation -->
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link" th:href="@{/timeline}">
<i class="bi bi-globe"></i> Timeline
</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/activities}" id="myActivitiesLink" style="display: none;">
<i class="bi bi-list-task"></i> My Activities
</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/activities/upload}" id="uploadLink" style="display: none;">
<i class="bi bi-cloud-upload"></i> Upload
</a>
</li>
</ul>
<!-- Right side navigation -->
<ul class="navbar-nav">
<!-- Authenticated user menu (hidden by default, shown by JS if JWT exists) -->
<li class="nav-item dropdown d-none" id="authUserMenu">
<a class="nav-link dropdown-toggle"
href="#"
id="userDropdown"
role="button"
data-bs-toggle="dropdown"
aria-expanded="false">
<i class="bi bi-person-circle"></i>
<span id="usernameDisplay">User</span>
</a>
<ul class="dropdown-menu dropdown-menu-end" aria-labelledby="userDropdown">
<li>
<a class="dropdown-item" th:href="@{/profile}">
<i class="bi bi-person"></i> Profile
</a>
</li>
<li>
<a class="dropdown-item" th:href="@{/settings}">
<i class="bi bi-gear"></i> Settings
</a>
</li>
<li><hr class="dropdown-divider"></li>
<li>
<a href="#" class="dropdown-item" onclick="event.preventDefault(); FitPubAuth.logout();">
<i class="bi bi-box-arrow-right"></i> Logout
</a>
</li>
</ul>
</li>
<!-- Guest menu (shown by default, hidden by JS if JWT exists) -->
<div id="guestMenu">
<li class="nav-item">
<a class="nav-link" th:href="@{/login}">
<i class="bi bi-box-arrow-in-right"></i> Login
</a>
</li>
<li class="nav-item">
<a class="nav-link" th:href="@{/register}">
<i class="bi bi-person-plus"></i> Register
</a>
</li>
</div>
</ul>
</div>
</div>
</nav>
<!-- Flash messages / Alerts -->
<div class="container mt-3" th:if="${message}">
<div class="alert alert-info alert-dismissible fade show" role="alert">
<span th:text="${message}"></span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
</div>
<div class="container mt-3" th:if="${error}">
<div class="alert alert-danger alert-dismissible fade show" role="alert">
<span th:text="${error}"></span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
</div>
<div class="container mt-3" th:if="${success}">
<div class="alert alert-success alert-dismissible fade show" role="alert">
<span th:text="${success}"></span>
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
</div>
</div>
<!-- Main content area -->
<main class="container my-4">
<th:block layout:fragment="content">
<!-- Page content will be inserted here -->
</th:block>
</main>
<!-- Footer -->
<footer class="bg-light mt-5 py-4">
<div class="container">
<div class="row">
<div class="col-md-6">
<h5>FitPub</h5>
<p class="text-muted">Federated Fitness Tracking</p>
<p class="text-small text-muted">
Share your fitness activities on the Fediverse
</p>
</div>
<div class="col-md-3">
<h6>Links</h6>
<ul class="list-unstyled">
<li><a href="#" class="text-decoration-none">About</a></li>
<li><a href="#" class="text-decoration-none">Privacy</a></li>
<li><a href="#" class="text-decoration-none">Terms</a></li>
</ul>
</div>
<div class="col-md-3">
<h6>Federation</h6>
<ul class="list-unstyled">
<li><a href="#" class="text-decoration-none">ActivityPub</a></li>
<li><a href="#" class="text-decoration-none">API Docs</a></li>
</ul>
</div>
</div>
<hr>
<div class="text-center text-muted text-small">
<p>&copy; 2024 FitPub. Open Source Software.</p>
</div>
</div>
</footer>
<!-- Bootstrap Bundle with Popper -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"
integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL"
crossorigin="anonymous"></script>
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@1.9.10"
integrity="sha384-D1Kt99CQMDuVetoL1lrYwg5t+9QdHe7NLX/SoJYkXDFfX37iInKRy5xLSi8nO7UC"
crossorigin="anonymous"></script>
<!-- Leaflet JS -->
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo="
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>
<!-- Custom JS -->
<script th:src="@{/js/auth.js}"></script>
<script th:src="@{/js/fitpub.js}"></script>
<!-- Additional scripts from child templates -->
<th:block layout:fragment="scripts"></th:block>
</body>
</html>