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

View file

@ -0,0 +1,81 @@
package org.operaton.fitpub.config;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.operaton.fitpub.model.entity.User;
import org.operaton.fitpub.repository.UserRepository;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Profile;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.util.Base64;
/**
* Development data initializer that creates a demo user for testing.
* Only active when the 'dev' profile is enabled.
*/
@Configuration
@Profile("dev")
@RequiredArgsConstructor
@Slf4j
public class DevDataInitializer {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
@Bean
public CommandLineRunner initDemoUser() {
return args -> {
// Check if demo user already exists
if (userRepository.findByUsername("demo").isPresent()) {
log.info("Demo user already exists, skipping initialization");
return;
}
log.info("Creating demo user for development...");
try {
// Generate RSA key pair for ActivityPub
KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA");
keyGen.initialize(2048);
KeyPair keyPair = keyGen.generateKeyPair();
String publicKey = "-----BEGIN PUBLIC KEY-----\n" +
Base64.getEncoder().encodeToString(keyPair.getPublic().getEncoded()) +
"\n-----END PUBLIC KEY-----";
String privateKey = "-----BEGIN PRIVATE KEY-----\n" +
Base64.getEncoder().encodeToString(keyPair.getPrivate().getEncoded()) +
"\n-----END PRIVATE KEY-----";
// Create demo user
User demoUser = User.builder()
.username("demo")
.email("demo@fitpub.local")
.passwordHash(passwordEncoder.encode("demo"))
.displayName("Demo User")
.bio("This is a demo account for testing FitPub features. Upload your FIT files and explore the federated fitness tracking platform!")
.publicKey(publicKey)
.privateKey(privateKey)
.build();
userRepository.save(demoUser);
log.info("=".repeat(80));
log.info("Demo user created successfully!");
log.info("Username: demo");
log.info("Password: demo");
log.info("Email: demo@fitpub.local");
log.info("=".repeat(80));
} catch (NoSuchAlgorithmException e) {
log.error("Failed to generate RSA key pair for demo user", e);
}
};
}
}

View file

@ -56,7 +56,8 @@ public class SecurityConfig {
.requestMatchers("/error").permitAll()
// Public endpoints - Web UI pages
.requestMatchers("/", "/login", "/register", "/timeline", "/activities", "/activities/**").permitAll()
.requestMatchers("/", "/login", "/register", "/timeline", "/timeline/**", "/activities", "/activities/**").permitAll()
.requestMatchers("/profile", "/profile/**", "/settings").permitAll() // Auth checked client-side
// Public endpoints - ActivityPub federation
.requestMatchers("/.well-known/**").permitAll()
@ -70,14 +71,23 @@ public class SecurityConfig {
// Public endpoints - Timeline API (read-only)
.requestMatchers(HttpMethod.GET, "/api/timeline/public").permitAll()
// Public endpoints - Activity track data (for public activities)
.requestMatchers(HttpMethod.GET, "/api/activities/*/track").permitAll()
// Public endpoints - User's public activities
.requestMatchers(HttpMethod.GET, "/api/activities/user/*").permitAll()
// Protected endpoints - Activities API
.requestMatchers("/api/activities/**").authenticated()
// Protected endpoints - Timeline API (user-specific)
.requestMatchers("/api/timeline/**").authenticated()
// Protected web pages
.requestMatchers("/profile", "/settings").authenticated()
// User API endpoints
.requestMatchers(HttpMethod.GET, "/api/users/me").authenticated()
.requestMatchers(HttpMethod.PUT, "/api/users/me").authenticated()
.requestMatchers(HttpMethod.GET, "/api/users/{username}").permitAll()
.requestMatchers(HttpMethod.GET, "/api/users/id/*").permitAll()
// All other requests require authentication
.anyRequest().authenticated()

View file

@ -102,25 +102,31 @@ public class ActivityController {
}
/**
* Lists all activities for the authenticated user.
* Lists all activities for the authenticated user with pagination.
*
* @param userDetails the authenticated user
* @return list of activities
* @param page page number (default: 0)
* @param size page size (default: 10)
* @return page of activities
*/
@GetMapping
public ResponseEntity<List<ActivityDTO>> getUserActivities(
@AuthenticationPrincipal UserDetails userDetails
public ResponseEntity<?> getUserActivities(
@AuthenticationPrincipal UserDetails userDetails,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size
) {
log.info("User {} retrieving activities", userDetails.getUsername());
log.info("User {} retrieving activities (page: {}, size: {})", userDetails.getUsername(), page, size);
UUID userId = getUserId(userDetails);
List<Activity> activities = fitFileService.getUserActivities(userId);
List<ActivityDTO> dtos = activities.stream()
.map(ActivityDTO::fromEntity)
.collect(Collectors.toList());
org.springframework.data.domain.Page<Activity> activityPage =
fitFileService.getUserActivitiesPaginated(userId, page, size);
return ResponseEntity.ok(dtos);
// Convert to DTOs
org.springframework.data.domain.Page<ActivityDTO> dtoPage = activityPage.map(ActivityDTO::fromEntity);
// Return Spring Page object with all pagination metadata
return ResponseEntity.ok(dtoPage);
}
/**
@ -180,4 +186,108 @@ public class ActivityController {
return ResponseEntity.noContent().build();
}
/**
* Lists public activities for a specific user by username.
*
* @param username the username
* @param page page number (default: 0)
* @param size page size (default: 10)
* @return page of public activities
*/
@GetMapping("/user/{username}")
public ResponseEntity<?> getUserPublicActivities(
@PathVariable String username,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size
) {
log.debug("Retrieving public activities for user: {}", username);
// Get user by username
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
// Get public activities only
org.springframework.data.domain.Pageable pageable =
org.springframework.data.domain.PageRequest.of(page, size,
org.springframework.data.domain.Sort.by("startedAt").descending());
org.springframework.data.domain.Page<Activity> activityPage =
fitFileService.getPublicActivitiesByUserId(user.getId(), pageable);
// Convert to DTOs
org.springframework.data.domain.Page<ActivityDTO> dtoPage = activityPage.map(ActivityDTO::fromEntity);
return ResponseEntity.ok(dtoPage);
}
/**
* Gets the GPS track data for an activity in GeoJSON format.
* Public activities can be accessed without authentication.
* Private/followers activities require authentication and proper access.
*
* @param id the activity ID
* @param userDetails the authenticated user (optional for public activities)
* @return GeoJSON FeatureCollection with track data
*/
@GetMapping("/{id}/track")
public ResponseEntity<?> getActivityTrack(
@PathVariable UUID id,
@AuthenticationPrincipal UserDetails userDetails
) {
log.debug("Retrieving track data for activity {}", id);
// First try to get the activity regardless of user
Activity activity = fitFileService.getActivityById(id);
if (activity == null) {
return ResponseEntity.notFound().build();
}
// Check visibility and access permissions
if (activity.getVisibility() != Activity.Visibility.PUBLIC) {
// Non-public activities require authentication
if (userDetails == null) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build();
}
UUID userId = getUserId(userDetails);
// Check if user owns the activity
if (!activity.getUserId().equals(userId)) {
// TODO: Check if user is following the activity owner (for FOLLOWERS visibility)
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
}
// Build GeoJSON FeatureCollection
ActivityDTO dto = ActivityDTO.fromEntity(activity);
if (dto.getSimplifiedTrack() == null) {
// Return empty FeatureCollection if no track data
return ResponseEntity.ok(java.util.Map.of(
"type", "FeatureCollection",
"features", java.util.List.of()
));
}
// Create GeoJSON Feature with the track
java.util.Map<String, Object> feature = new java.util.LinkedHashMap<>();
feature.put("type", "Feature");
feature.put("geometry", dto.getSimplifiedTrack());
// Add properties
java.util.Map<String, Object> properties = new java.util.LinkedHashMap<>();
properties.put("title", activity.getTitle());
properties.put("activityType", activity.getActivityType().name());
properties.put("distance", activity.getTotalDistance());
properties.put("duration", activity.getTotalDurationSeconds());
feature.put("properties", properties);
// Create FeatureCollection
java.util.Map<String, Object> geoJson = new java.util.LinkedHashMap<>();
geoJson.put("type", "FeatureCollection");
geoJson.put("features", java.util.List.of(feature));
return ResponseEntity.ok(geoJson);
}
}

View file

@ -0,0 +1,67 @@
package org.operaton.fitpub.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
/**
* Controller for user profile view pages.
*/
@Controller
public class ProfileViewController {
/**
* Current user's profile page.
* Shows own profile with edit capabilities.
*
* @param model the model
* @return profile template
*/
@GetMapping("/profile")
public String myProfile(Model model) {
model.addAttribute("pageTitle", "My Profile");
return "profile/view";
}
/**
* Profile edit page.
* Allows user to edit their profile information.
*
* @param model the model
* @return profile edit template
*/
@GetMapping("/profile/edit")
public String editProfile(Model model) {
model.addAttribute("pageTitle", "Edit Profile");
return "profile/edit";
}
/**
* Settings page.
* Allows user to access various settings.
*
* @param model the model
* @return settings template
*/
@GetMapping("/settings")
public String settings(Model model) {
model.addAttribute("pageTitle", "Settings");
return "settings";
}
/**
* Public user profile page by username.
* Shows public profile of any user.
*
* @param username the username
* @param model the model
* @return profile template
*/
@GetMapping("/users/{username}")
public String userProfile(@PathVariable String username, Model model) {
model.addAttribute("pageTitle", "Profile - @" + username);
model.addAttribute("username", username);
return "profile/public";
}
}

View file

@ -0,0 +1,50 @@
package org.operaton.fitpub.controller;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
/**
* Controller for timeline view pages.
*/
@Controller
public class TimelineViewController {
/**
* Public timeline page - shows all public activities.
*
* @param model the model
* @return timeline template
*/
@GetMapping("/timeline")
public String publicTimeline(Model model) {
model.addAttribute("pageTitle", "Public Timeline");
return "timeline/public";
}
/**
* Federated timeline page - shows activities from followed users.
* Requires authentication.
*
* @param model the model
* @return timeline template
*/
@GetMapping("/timeline/federated")
public String federatedTimeline(Model model) {
model.addAttribute("pageTitle", "Federated Timeline");
return "timeline/federated";
}
/**
* User timeline page - shows current user's own activities.
* Requires authentication.
*
* @param model the model
* @return timeline template
*/
@GetMapping("/timeline/user")
public String userTimeline(Model model) {
model.addAttribute("pageTitle", "My Timeline");
return "timeline/user";
}
}

View file

@ -0,0 +1,109 @@
package org.operaton.fitpub.controller;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.operaton.fitpub.model.dto.UserDTO;
import org.operaton.fitpub.model.dto.UserUpdateRequest;
import org.operaton.fitpub.model.entity.User;
import org.operaton.fitpub.repository.UserRepository;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
/**
* REST controller for user profile operations.
*/
@RestController
@RequestMapping("/api/users")
@RequiredArgsConstructor
@Slf4j
public class UserController {
private final UserRepository userRepository;
/**
* Get current user's profile.
*
* @param userDetails the authenticated user
* @return user profile
*/
@GetMapping("/me")
public ResponseEntity<UserDTO> getCurrentUser(@AuthenticationPrincipal UserDetails userDetails) {
log.debug("User {} retrieving own profile", userDetails.getUsername());
User user = userRepository.findByUsername(userDetails.getUsername())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return ResponseEntity.ok(UserDTO.fromEntity(user));
}
/**
* Update current user's profile.
*
* @param request the update request
* @param userDetails the authenticated user
* @return updated user profile
*/
@PutMapping("/me")
public ResponseEntity<UserDTO> updateCurrentUser(
@Valid @RequestBody UserUpdateRequest request,
@AuthenticationPrincipal UserDetails userDetails
) {
log.info("User {} updating profile", userDetails.getUsername());
User user = userRepository.findByUsername(userDetails.getUsername())
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
// Update allowed fields
if (request.getDisplayName() != null) {
user.setDisplayName(request.getDisplayName().trim());
}
if (request.getBio() != null) {
user.setBio(request.getBio().trim());
}
if (request.getAvatarUrl() != null) {
user.setAvatarUrl(request.getAvatarUrl().trim());
}
User updated = userRepository.save(user);
return ResponseEntity.ok(UserDTO.fromEntity(updated));
}
/**
* Get user profile by username.
*
* @param username the username
* @return user profile
*/
@GetMapping("/{username}")
public ResponseEntity<UserDTO> getUserByUsername(@PathVariable String username) {
log.debug("Retrieving profile for username: {}", username);
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + username));
return ResponseEntity.ok(UserDTO.fromEntity(user));
}
/**
* Get user profile by ID.
*
* @param id the user ID
* @return user profile
*/
@GetMapping("/id/{id}")
public ResponseEntity<UserDTO> getUserById(@PathVariable UUID id) {
log.debug("Retrieving profile for user ID: {}", id);
User user = userRepository.findById(id)
.orElseThrow(() -> new UsernameNotFoundException("User not found"));
return ResponseEntity.ok(UserDTO.fromEntity(user));
}
}

View file

@ -1,15 +1,24 @@
package org.operaton.fitpub.model.dto;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.locationtech.jts.geom.Coordinate;
import org.locationtech.jts.geom.LineString;
import org.operaton.fitpub.model.entity.Activity;
import java.math.BigDecimal;
import java.time.Duration;
import java.time.LocalDateTime;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.stream.Collectors;
import java.util.stream.Stream;
/**
* DTO for Activity data transfer.
@ -36,6 +45,40 @@ public class ActivityDTO {
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
// Map rendering data
private Map<String, Object> simplifiedTrack; // GeoJSON LineString
private List<Map<String, Object>> trackPoints; // Full track points from JSONB
// Convenience getters for flattened metrics (for frontend compatibility)
public Integer getAverageHeartRate() {
return metrics != null ? metrics.getAverageHeartRate() : null;
}
public Integer getMaxHeartRate() {
return metrics != null ? metrics.getMaxHeartRate() : null;
}
public Integer getAverageCadence() {
return metrics != null ? metrics.getAverageCadence() : null;
}
public BigDecimal getAverageSpeed() {
return metrics != null ? metrics.getAverageSpeed() : null;
}
public BigDecimal getMaxSpeed() {
return metrics != null ? metrics.getMaxSpeed() : null;
}
public Integer getCalories() {
return metrics != null ? metrics.getCalories() : null;
}
// Alias for frontend compatibility
public Long getTotalDuration() {
return totalDurationSeconds;
}
/**
* Creates a DTO from an Activity entity.
*/
@ -63,6 +106,65 @@ public class ActivityDTO {
builder.metrics(ActivityMetricsDTO.fromEntity(activity.getMetrics()));
}
// Convert simplified track to GeoJSON
if (activity.getSimplifiedTrack() != null) {
builder.simplifiedTrack(lineStringToGeoJson(activity.getSimplifiedTrack()));
}
// Parse track points from JSONB
if (activity.getTrackPointsJson() != null && !activity.getTrackPointsJson().isEmpty()) {
builder.trackPoints(parseTrackPoints(activity.getTrackPointsJson()));
}
return builder.build();
}
/**
* Converts a JTS LineString to GeoJSON format.
*/
private static Map<String, Object> lineStringToGeoJson(LineString lineString) {
Map<String, Object> geoJson = new LinkedHashMap<>();
geoJson.put("type", "LineString");
List<List<Double>> coordinates = Stream.of(lineString.getCoordinates())
.map(coord -> List.of(coord.getX(), coord.getY()))
.collect(Collectors.toList());
geoJson.put("coordinates", coordinates);
return geoJson;
}
/**
* Parses track points from JSONB string.
*/
private static List<Map<String, Object>> parseTrackPoints(String trackPointsJson) {
try {
ObjectMapper mapper = new ObjectMapper();
JsonNode root = mapper.readTree(trackPointsJson);
if (root.isArray()) {
List<Map<String, Object>> trackPoints = new java.util.ArrayList<>();
for (JsonNode node : root) {
Map<String, Object> point = new LinkedHashMap<>();
if (node.has("timestamp")) point.put("timestamp", node.get("timestamp").asText());
if (node.has("latitude")) point.put("latitude", node.get("latitude").asDouble());
if (node.has("longitude")) point.put("longitude", node.get("longitude").asDouble());
if (node.has("elevation")) point.put("elevation", node.get("elevation").asDouble());
if (node.has("heartRate")) point.put("heartRate", node.get("heartRate").asInt());
if (node.has("cadence")) point.put("cadence", node.get("cadence").asInt());
if (node.has("speed")) point.put("speed", node.get("speed").asDouble());
if (node.has("power")) point.put("power", node.get("power").asInt());
if (node.has("temperature")) point.put("temperature", node.get("temperature").asDouble());
trackPoints.add(point);
}
return trackPoints;
}
} catch (Exception e) {
// Log error but don't fail the entire DTO creation
System.err.println("Error parsing track points JSON: " + e.getMessage());
}
return null;
}
}

View file

@ -0,0 +1,63 @@
package org.operaton.fitpub.model.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.operaton.fitpub.model.entity.User;
import java.time.LocalDateTime;
import java.util.UUID;
/**
* DTO for User data transfer.
* Used for public user profiles (excludes sensitive data like password hash and private keys).
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserDTO {
private UUID id;
private String username;
private String email; // Only shown to the user themselves
private String displayName;
private String bio;
private String avatarUrl;
private LocalDateTime createdAt;
private LocalDateTime updatedAt;
/**
* Creates a DTO from a User entity.
* Note: email should only be included when user is viewing their own profile.
*/
public static UserDTO fromEntity(User user) {
return UserDTO.builder()
.id(user.getId())
.username(user.getUsername())
.email(user.getEmail())
.displayName(user.getDisplayName())
.bio(user.getBio())
.avatarUrl(user.getAvatarUrl())
.createdAt(user.getCreatedAt())
.updatedAt(user.getUpdatedAt())
.build();
}
/**
* Creates a public DTO from a User entity (excludes email).
* Use this when returning user data to other users.
*/
public static UserDTO fromEntityPublic(User user) {
return UserDTO.builder()
.id(user.getId())
.username(user.getUsername())
.displayName(user.getDisplayName())
.bio(user.getBio())
.avatarUrl(user.getAvatarUrl())
.createdAt(user.getCreatedAt())
.updatedAt(user.getUpdatedAt())
.build();
}
}

View file

@ -0,0 +1,27 @@
package org.operaton.fitpub.model.dto;
import jakarta.validation.constraints.Size;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import org.hibernate.validator.constraints.URL;
/**
* DTO for user profile update requests.
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class UserUpdateRequest {
@Size(max = 100, message = "Display name must not exceed 100 characters")
private String displayName;
@Size(max = 500, message = "Bio must not exceed 500 characters")
private String bio;
@URL(message = "Avatar URL must be a valid URL")
private String avatarUrl;
}

View file

@ -45,6 +45,7 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
* Find all public activities for a user.
*
* @param userId the user ID
* @param visibility the visibility level
* @return list of activities
*/
List<Activity> findByUserIdAndVisibilityOrderByStartedAtDesc(
@ -52,6 +53,20 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
Activity.Visibility visibility
);
/**
* Find activities for a user by visibility with pagination.
*
* @param userId the user ID
* @param visibility the visibility level
* @param pageable pagination parameters
* @return page of activities
*/
Page<Activity> findByUserIdAndVisibilityOrderByStartedAtDesc(
UUID userId,
Activity.Visibility visibility,
Pageable pageable
);
/**
* Find activities by type for a user.
*

View file

@ -339,6 +339,19 @@ public class FitFileService {
return activityRepository.findByIdAndUserId(activityId, userId).orElse(null);
}
/**
* Retrieves an activity by ID without user authorization check.
* This is used for public activity access (e.g., viewing public tracks).
* Caller is responsible for checking visibility and access permissions.
*
* @param activityId the activity ID
* @return the activity or null if not found
*/
@Transactional(readOnly = true)
public Activity getActivityById(UUID activityId) {
return activityRepository.findById(activityId).orElse(null);
}
/**
* Retrieves all activities for a user.
*
@ -350,6 +363,33 @@ public class FitFileService {
return activityRepository.findByUserIdOrderByStartedAtDesc(userId);
}
/**
* Retrieves activities for a user with pagination.
*
* @param userId the user ID
* @param page page number (0-indexed)
* @param size page size
* @return page of activities
*/
@Transactional(readOnly = true)
public org.springframework.data.domain.Page<Activity> getUserActivitiesPaginated(UUID userId, int page, int size) {
org.springframework.data.domain.Pageable pageable =
org.springframework.data.domain.PageRequest.of(page, size, org.springframework.data.domain.Sort.by("startedAt").descending());
return activityRepository.findByUserIdOrderByStartedAtDesc(userId, pageable);
}
/**
* Retrieves public activities for a user with pagination.
*
* @param userId the user ID
* @param pageable pagination parameters
* @return page of public activities
*/
@Transactional(readOnly = true)
public org.springframework.data.domain.Page<Activity> getPublicActivitiesByUserId(UUID userId, org.springframework.data.domain.Pageable pageable) {
return activityRepository.findByUserIdAndVisibilityOrderByStartedAtDesc(userId, Activity.Visibility.PUBLIC, pageable);
}
/**
* Update an existing activity's metadata.
*

View file

@ -7,13 +7,18 @@ spring:
jpa:
hibernate:
ddl-auto: update # Auto-update schema in dev mode
ddl-auto: validate # Use Flyway for schema management, even in dev
show-sql: true # Show SQL queries in console
properties:
hibernate:
format_sql: true # Format SQL for readability
use_sql_comments: true # Add comments to SQL
flyway:
enabled: true # Use Flyway for migrations
baseline-on-migrate: true
locations: classpath:db/migration
# Development-specific FitPub configuration
fitpub:
domain: ${FITPUB_DOMAIN:localhost:8080}

View file

@ -2,6 +2,10 @@ spring:
application:
name: fitpub
# Default to dev profile if not specified
profiles:
active: ${SPRING_PROFILES_ACTIVE:dev}
# Datasource configuration is handled by Testcontainers in dev mode
# For production, set these via environment variables:
# - SPRING_DATASOURCE_URL

View file

@ -135,6 +135,94 @@ body {
font-weight: 600;
}
/* Timeline Cards */
.timeline-card {
transition: transform 0.2s, box-shadow 0.2s;
}
.timeline-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
}
.timeline-card .user-avatar {
flex-shrink: 0;
}
.timeline-card .avatar-placeholder {
width: 48px;
height: 48px;
background-color: var(--light-color);
display: flex;
align-items: center;
justify-content: center;
font-size: 2rem;
color: #9ca3af;
}
.activity-preview-map {
background-color: var(--light-color);
position: relative;
}
.activity-preview-map .leaflet-container {
border-radius: 8px;
}
/* Timeline Metrics */
.timeline-card .metric-card {
padding: 0.75rem;
margin-bottom: 0;
}
.timeline-card .metric-value {
font-size: 1.25rem;
}
.timeline-card .metric-label {
font-size: 0.75rem;
}
/* Profile Pages */
.avatar-placeholder-large {
width: 120px;
height: 120px;
background-color: var(--light-color);
display: flex;
align-items: center;
justify-content: center;
font-size: 4rem;
color: #9ca3af;
}
.stat-card {
padding: 1rem;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
color: var(--primary-color);
}
.stat-label {
font-size: 0.875rem;
color: #6b7280;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.activity-item {
transition: background-color 0.2s;
}
.activity-item:hover {
background-color: var(--light-color);
border-radius: var(--border-radius);
padding: 0.5rem;
margin: -0.5rem;
}
/* Charts */
.chart-container {
position: relative;

View file

@ -222,8 +222,12 @@ function createActivityMap(containerId, geoJsonData, options = {}) {
if (mapOptions.fitBounds) {
try {
const bounds = trackLayer.getBounds();
console.log('Track bounds:', bounds);
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [50, 50] });
console.log('Map bounds fitted successfully');
} else {
console.warn('Track bounds are invalid');
}
} catch (e) {
console.warn('Could not fit map bounds:', e);
@ -243,9 +247,22 @@ function createActivityMap(containerId, geoJsonData, options = {}) {
map.setView([0, 0], 2);
}
// Invalidate size to ensure proper rendering
// Invalidate size to ensure proper rendering and re-fit bounds
setTimeout(() => {
map.invalidateSize();
// Re-fit bounds after size invalidation if we have a track
if (mapOptions.fitBounds && map.trackLayer) {
try {
const bounds = map.trackLayer.getBounds();
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [50, 50] });
console.log('Map bounds re-fitted after invalidateSize');
}
} catch (e) {
console.warn('Could not re-fit bounds after invalidateSize:', e);
}
}
}, 100);
return map;

View file

@ -0,0 +1,439 @@
/**
* Timeline functionality for FitPub
* Handles loading and displaying timeline activities with preview maps
*/
const FitPubTimeline = {
currentPage: 0,
totalPages: 0,
timelineType: 'public',
/**
* Initialize the timeline
* @param {string} type - Timeline type: 'public', 'federated', or 'user'
*/
init: function(type) {
this.timelineType = type;
this.loadTimeline(0);
},
/**
* Load timeline activities
* @param {number} page - Page number to load
*/
loadTimeline: async function(page) {
const loadingIndicator = document.getElementById('loadingIndicator');
const errorAlert = document.getElementById('errorAlert');
const errorMessage = document.getElementById('errorMessage');
const timelineList = document.getElementById('timelineList');
const emptyState = document.getElementById('emptyState');
const pagination = document.getElementById('pagination');
try {
// Show loading
loadingIndicator.classList.remove('d-none');
timelineList.classList.add('d-none');
emptyState.classList.add('d-none');
errorAlert.classList.add('d-none');
pagination.classList.add('d-none');
// Determine endpoint
let endpoint;
let fetchOptions = {};
switch (this.timelineType) {
case 'public':
endpoint = `/api/timeline/public?page=${page}&size=20`;
break;
case 'federated':
endpoint = `/api/timeline/federated?page=${page}&size=20`;
fetchOptions = { useAuth: true };
break;
case 'user':
endpoint = `/api/timeline/user?page=${page}&size=20`;
fetchOptions = { useAuth: true };
break;
default:
throw new Error('Invalid timeline type');
}
// Fetch timeline data
const response = fetchOptions.useAuth
? await FitPubAuth.authenticatedFetch(endpoint)
: await fetch(endpoint);
if (response.ok) {
const data = await response.json();
// Hide loading
loadingIndicator.classList.add('d-none');
if (data.content && data.content.length > 0) {
this.renderTimeline(data.content);
this.renderPagination(data);
timelineList.classList.remove('d-none');
pagination.classList.remove('d-none');
} else {
emptyState.classList.remove('d-none');
}
this.totalPages = data.totalPages;
this.currentPage = data.number;
} else {
throw new Error('Failed to load timeline');
}
} catch (error) {
console.error('Error loading timeline:', error);
loadingIndicator.classList.add('d-none');
errorMessage.textContent = 'Failed to load timeline. Please try again.';
errorAlert.classList.remove('d-none');
}
},
/**
* Render timeline activities
* @param {Array} activities - Array of timeline activity objects
*/
renderTimeline: function(activities) {
const timelineList = document.getElementById('timelineList');
timelineList.innerHTML = activities.map((activity, index) => {
const mapId = `map-${activity.id}`;
return `
<div class="timeline-card card mb-4">
<div class="card-body">
<!-- User Info -->
<div class="d-flex align-items-center mb-3">
<a href="/users/${activity.username}" class="user-avatar me-3 text-decoration-none">
${activity.avatarUrl
? `<img src="${activity.avatarUrl}" alt="${this.escapeHtml(activity.displayName || activity.username)}" class="rounded-circle" width="48" height="48">`
: `<div class="avatar-placeholder rounded-circle">
<i class="bi bi-person-circle"></i>
</div>`
}
</a>
<div class="flex-grow-1">
<a href="/users/${activity.username}" class="text-decoration-none text-dark">
<div class="fw-bold">${this.escapeHtml(activity.displayName || activity.username)}</div>
</a>
<div class="text-muted small">
<a href="/users/${activity.username}" class="text-decoration-none text-muted">
@${this.escapeHtml(activity.username)}
</a>
${!activity.isLocal ? ' <i class="bi bi-globe2" title="Federated user"></i>' : ''}
${this.formatTimeAgo(activity.startedAt)}
</div>
</div>
<div>
<span class="activity-type-badge activity-type-${activity.activityType.toLowerCase()}">
${activity.activityType}
</span>
</div>
</div>
<!-- Activity Title and Description -->
<h5 class="card-title">
<a href="/activities/${activity.id}" class="text-decoration-none text-dark">
${this.escapeHtml(activity.title || 'Untitled Activity')}
</a>
</h5>
${activity.description
? `<p class="card-text">${this.escapeHtml(activity.description).substring(0, 200)}${activity.description.length > 200 ? '...' : ''}</p>`
: ''
}
<!-- Activity Metrics -->
<div class="row text-center mb-3">
<div class="col-6 col-md-3">
<div class="metric-card">
<div class="metric-value">${this.formatDistance(activity.totalDistance)}</div>
<div class="metric-label">Distance</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="metric-card">
<div class="metric-value">${this.formatDuration(activity.totalDurationSeconds)}</div>
<div class="metric-label">Duration</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="metric-card">
<div class="metric-value">${this.formatPace(activity.totalDurationSeconds, activity.totalDistance)}</div>
<div class="metric-label">Avg Pace</div>
</div>
</div>
<div class="col-6 col-md-3">
<div class="metric-card">
<div class="metric-value">${activity.elevationGain ? Math.round(activity.elevationGain) + 'm' : 'N/A'}</div>
<div class="metric-label">Elevation</div>
</div>
</div>
</div>
<!-- Preview Map -->
<div class="activity-preview-map" id="${mapId}" style="height: 300px; border-radius: 8px; margin-bottom: 1rem;">
<!-- Map will be rendered here -->
</div>
<!-- Activity Actions -->
<div class="d-flex gap-2">
<a href="/activities/${activity.id}" class="btn btn-sm btn-outline-primary">
<i class="bi bi-eye"></i> View Details
</a>
<span class="ms-auto text-muted small">
<i class="bi bi-${this.getVisibilityIcon(activity.visibility)}"></i>
${activity.visibility}
</span>
</div>
</div>
</div>
`;
}).join('');
// Render maps after DOM is updated
setTimeout(() => {
activities.forEach(activity => {
this.renderPreviewMap(activity);
});
}, 100);
},
/**
* Render preview map for an activity
* @param {Object} activity - Activity object
*/
renderPreviewMap: async function(activity) {
const mapId = `map-${activity.id}`;
const mapElement = document.getElementById(mapId);
if (!mapElement) {
console.warn('Map element not found:', mapId);
return;
}
try {
// Fetch track data
const response = await fetch(`/api/activities/${activity.id}/track`);
if (!response.ok) {
throw new Error('Failed to load track data');
}
const trackData = await response.json();
if (!trackData.features || trackData.features.length === 0) {
mapElement.innerHTML = '<div class="d-flex align-items-center justify-content-center h-100 bg-light"><p class="text-muted">No GPS data available</p></div>';
return;
}
// Initialize map
const map = L.map(mapId, {
zoomControl: true,
scrollWheelZoom: false,
dragging: true,
touchZoom: true
});
// Add tile layer
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
attribution: '© OpenStreetMap contributors',
maxZoom: 18
}).addTo(map);
// Add track to map
const geoJsonLayer = L.geoJSON(trackData, {
style: {
color: '#0d6efd',
weight: 3,
opacity: 0.8
}
}).addTo(map);
// Fit map to track bounds
map.fitBounds(geoJsonLayer.getBounds(), { padding: [20, 20] });
// Add start/finish markers
const coordinates = trackData.features[0].geometry.coordinates;
if (coordinates.length > 0) {
// Start marker (green)
const startCoord = coordinates[0];
L.circleMarker([startCoord[1], startCoord[0]], {
radius: 6,
fillColor: '#28a745',
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 1
}).addTo(map);
// Finish marker (red)
const endCoord = coordinates[coordinates.length - 1];
L.circleMarker([endCoord[1], endCoord[0]], {
radius: 6,
fillColor: '#dc3545',
color: '#fff',
weight: 2,
opacity: 1,
fillOpacity: 1
}).addTo(map);
}
} catch (error) {
console.error('Error rendering map:', error);
mapElement.innerHTML = '<div class="d-flex align-items-center justify-content-center h-100 bg-light"><p class="text-muted">Failed to load map</p></div>';
}
},
/**
* Render pagination controls
* @param {Object} data - Pagination data from API
*/
renderPagination: function(data) {
const paginationList = document.getElementById('paginationList');
let html = '';
// Previous button
html += `
<li class="page-item ${data.first ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="FitPubTimeline.changePage(${data.number - 1}); return false;">
<i class="bi bi-chevron-left"></i>
</a>
</li>
`;
// Page numbers
const startPage = Math.max(0, data.number - 2);
const endPage = Math.min(data.totalPages - 1, data.number + 2);
if (startPage > 0) {
html += `<li class="page-item disabled"><span class="page-link">...</span></li>`;
}
for (let i = startPage; i <= endPage; i++) {
html += `
<li class="page-item ${i === data.number ? 'active' : ''}">
<a class="page-link" href="#" onclick="FitPubTimeline.changePage(${i}); return false;">${i + 1}</a>
</li>
`;
}
if (endPage < data.totalPages - 1) {
html += `<li class="page-item disabled"><span class="page-link">...</span></li>`;
}
// Next button
html += `
<li class="page-item ${data.last ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="FitPubTimeline.changePage(${data.number + 1}); return false;">
<i class="bi bi-chevron-right"></i>
</a>
</li>
`;
paginationList.innerHTML = html;
},
/**
* Change page
* @param {number} page - Page number
*/
changePage: function(page) {
this.loadTimeline(page);
window.scrollTo({ top: 0, behavior: 'smooth' });
},
/**
* Format distance in meters to km
* @param {number} meters - Distance in meters
* @returns {string} Formatted distance
*/
formatDistance: function(meters) {
if (!meters) return 'N/A';
if (meters >= 1000) {
return (meters / 1000).toFixed(1) + ' km';
}
return Math.round(meters) + ' m';
},
/**
* Format duration in seconds
* @param {number} seconds - Duration in seconds
* @returns {string} Formatted duration
*/
formatDuration: function(seconds) {
if (!seconds) return 'N/A';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
return `${hours}h ${minutes}m`;
}
if (minutes > 0) {
return `${minutes}m ${secs}s`;
}
return `${secs}s`;
},
/**
* Format pace (min/km)
* @param {number} seconds - Total duration in seconds
* @param {number} meters - Total distance in meters
* @returns {string} Formatted pace
*/
formatPace: function(seconds, meters) {
if (!seconds || !meters || meters === 0) return 'N/A';
const km = meters / 1000;
const paceSeconds = seconds / km;
const paceMinutes = Math.floor(paceSeconds / 60);
const paceSecs = Math.floor(paceSeconds % 60);
return `${paceMinutes}:${paceSecs.toString().padStart(2, '0')}/km`;
},
/**
* Format timestamp to "time ago" format
* @param {string} timestamp - ISO timestamp
* @returns {string} Time ago string
*/
formatTimeAgo: function(timestamp) {
const date = new Date(timestamp);
const now = new Date();
const secondsAgo = Math.floor((now - date) / 1000);
if (secondsAgo < 60) return 'just now';
if (secondsAgo < 3600) return `${Math.floor(secondsAgo / 60)}m ago`;
if (secondsAgo < 86400) return `${Math.floor(secondsAgo / 3600)}h ago`;
if (secondsAgo < 604800) return `${Math.floor(secondsAgo / 86400)}d ago`;
return date.toLocaleDateString();
},
/**
* Get visibility icon
* @param {string} visibility - Visibility level
* @returns {string} Bootstrap icon name
*/
getVisibilityIcon: function(visibility) {
switch (visibility) {
case 'PUBLIC': return 'globe';
case 'FOLLOWERS': return 'people';
case 'PRIVATE': return 'lock';
default: return 'question-circle';
}
},
/**
* Escape HTML to prevent XSS
* @param {string} text - Text to escape
* @returns {string} Escaped text
*/
escapeHtml: function(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
};

View file

@ -296,26 +296,75 @@
coordinates: simplifiedTrack.coordinates
};
// Create map
FitPub.createActivityMap('activityMap', geoJson, {
showStartEnd: true,
fitBounds: true
});
// Create map (needs to be done after container is visible)
setTimeout(() => {
const map = FitPub.createActivityMap('activityMap', geoJson, {
showStartEnd: true,
fitBounds: true
});
// Force fit bounds again after map is fully rendered
if (map && map.trackLayer) {
setTimeout(() => {
try {
const bounds = map.trackLayer.getBounds();
if (bounds.isValid()) {
map.fitBounds(bounds, { padding: [50, 50] });
}
} catch (e) {
console.warn('Could not fit bounds on second attempt:', e);
}
}, 200);
}
}, 50);
}
function renderElevationChart(trackPoints) {
const elevationData = trackPoints
.filter(p => p.elevation != null)
.map((p, index) => ({
x: index,
y: p.elevation
}));
// Calculate cumulative distance and prepare elevation data
let cumulativeDistance = 0;
const elevationData = [];
for (let i = 0; i < trackPoints.length; i++) {
const point = trackPoints[i];
// Calculate distance from previous point (simple Haversine approximation)
if (i > 0 && point.latitude && point.longitude) {
const prev = trackPoints[i - 1];
if (prev.latitude && prev.longitude) {
const distance = calculateDistance(
prev.latitude, prev.longitude,
point.latitude, point.longitude
);
cumulativeDistance += distance;
}
}
// Add point if it has elevation data
if (point.elevation != null) {
elevationData.push({
distance: cumulativeDistance,
elevation: point.elevation
});
}
}
if (elevationData.length > 0) {
FitPub.createElevationChart('elevationChart', elevationData);
}
}
// Haversine formula to calculate distance between two GPS points
function calculateDistance(lat1, lon1, lat2, lon2) {
const R = 6371000; // Earth's radius in meters
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLon = (lon2 - lon1) * Math.PI / 180;
const a = Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(lat1 * Math.PI / 180) * Math.cos(lat2 * Math.PI / 180) *
Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c;
}
function renderAdditionalMetrics(activity) {
let hasAdditionalMetrics = false;

View file

@ -197,8 +197,7 @@
crossorigin="anonymous"></script>
<!-- Chart.js -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"
crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<!-- Custom JS -->
<script th:src="@{/js/auth.js}"></script>

View file

@ -0,0 +1,243 @@
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<head>
<title>Edit Profile</title>
</head>
<body>
<div layout:fragment="content">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<i class="bi bi-pencil"></i> Edit Profile
</h4>
</div>
<div class="card-body">
<!-- Loading Indicator -->
<div id="loadingIndicator" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2 text-muted">Loading profile...</p>
</div>
<!-- Edit Form -->
<form id="editProfileForm" class="d-none">
<!-- Display Name -->
<div class="mb-3">
<label for="displayName" class="form-label">Display Name</label>
<input type="text" class="form-control" id="displayName" name="displayName" maxlength="100">
<div class="form-text">Your name as it appears to others</div>
</div>
<!-- Bio -->
<div class="mb-3">
<label for="bio" class="form-label">Bio</label>
<textarea class="form-control" id="bio" name="bio" rows="4" maxlength="500"></textarea>
<div class="form-text">
<span id="bioCharCount">0</span>/500 characters
</div>
</div>
<!-- Avatar URL -->
<div class="mb-3">
<label for="avatarUrl" class="form-label">Avatar URL</label>
<input type="url" class="form-control" id="avatarUrl" name="avatarUrl" placeholder="https://example.com/avatar.jpg">
<div class="form-text">URL to your profile picture</div>
</div>
<!-- Avatar Preview -->
<div class="mb-3" id="avatarPreviewContainer" style="display: none;">
<label class="form-label">Avatar Preview</label>
<div>
<img id="avatarPreview" src="" alt="Avatar preview" class="rounded-circle" width="100" height="100">
</div>
</div>
<!-- Email (read-only for now) -->
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<input type="email" class="form-control" id="email" name="email" readonly>
<div class="form-text">Email cannot be changed here</div>
</div>
<!-- Username (read-only) -->
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<input type="text" class="form-control" id="username" name="username" readonly>
<div class="form-text">Username cannot be changed</div>
</div>
<!-- Error Alert -->
<div id="errorAlert" class="alert alert-danger d-none" role="alert">
<i class="bi bi-exclamation-triangle-fill"></i>
<span id="errorMessage"></span>
</div>
<!-- Success Alert -->
<div id="successAlert" class="alert alert-success d-none" role="alert">
<i class="bi bi-check-circle-fill"></i>
Profile updated successfully!
</div>
<!-- Action Buttons -->
<div class="d-flex justify-content-between">
<a th:href="@{/profile}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-left"></i> Cancel
</a>
<button type="submit" class="btn btn-primary" id="saveBtn">
<span id="saveBtnText">
<i class="bi bi-save"></i> Save Changes
</span>
<span id="saveBtnLoading" class="d-none">
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
Saving...
</span>
</button>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
<!-- Custom Scripts -->
<th:block layout:fragment="scripts">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
// Redirect to login if not authenticated
if (!FitPubAuth.isAuthenticated()) {
window.location.href = '/login';
return;
}
const form = document.getElementById('editProfileForm');
const bioInput = document.getElementById('bio');
const bioCharCount = document.getElementById('bioCharCount');
const avatarUrlInput = document.getElementById('avatarUrl');
const avatarPreview = document.getElementById('avatarPreview');
const avatarPreviewContainer = document.getElementById('avatarPreviewContainer');
loadProfile();
// Bio character counter
bioInput.addEventListener('input', function() {
bioCharCount.textContent = bioInput.value.length;
});
// Avatar preview
avatarUrlInput.addEventListener('input', function() {
const url = avatarUrlInput.value.trim();
if (url) {
avatarPreview.src = url;
avatarPreviewContainer.style.display = 'block';
} else {
avatarPreviewContainer.style.display = 'none';
}
});
// Form submission
form.addEventListener('submit', async function(e) {
e.preventDefault();
// Hide alerts
document.getElementById('errorAlert').classList.add('d-none');
document.getElementById('successAlert').classList.add('d-none');
// Show loading state
document.getElementById('saveBtnText').classList.add('d-none');
document.getElementById('saveBtnLoading').classList.remove('d-none');
document.getElementById('saveBtn').disabled = true;
try {
const formData = {
displayName: document.getElementById('displayName').value.trim(),
bio: document.getElementById('bio').value.trim(),
avatarUrl: document.getElementById('avatarUrl').value.trim()
};
const response = await FitPubAuth.authenticatedFetch('/api/users/me', {
method: 'PUT',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(formData)
});
if (response.ok) {
// Show success message
document.getElementById('successAlert').classList.remove('d-none');
// Scroll to top
window.scrollTo({ top: 0, behavior: 'smooth' });
// Redirect to profile after 2 seconds
setTimeout(() => {
window.location.href = '/profile';
}, 2000);
} else {
const errorData = await response.json();
throw new Error(errorData.message || 'Failed to update profile');
}
} catch (error) {
console.error('Error updating profile:', error);
document.getElementById('errorMessage').textContent = error.message;
document.getElementById('errorAlert').classList.remove('d-none');
// Reset button state
document.getElementById('saveBtnText').classList.remove('d-none');
document.getElementById('saveBtnLoading').classList.add('d-none');
document.getElementById('saveBtn').disabled = false;
}
});
async function loadProfile() {
try {
const response = await FitPubAuth.authenticatedFetch('/api/users/me');
if (response.ok) {
const user = await response.json();
populateForm(user);
// Show form, hide loading
document.getElementById('loadingIndicator').classList.add('d-none');
document.getElementById('editProfileForm').classList.remove('d-none');
} else {
throw new Error('Failed to load profile');
}
} catch (error) {
console.error('Error loading profile:', error);
document.getElementById('loadingIndicator').classList.add('d-none');
document.getElementById('errorMessage').textContent = 'Failed to load profile. Please try again.';
document.getElementById('errorAlert').classList.remove('d-none');
}
}
function populateForm(user) {
document.getElementById('displayName').value = user.displayName || '';
document.getElementById('bio').value = user.bio || '';
document.getElementById('avatarUrl').value = user.avatarUrl || '';
document.getElementById('email').value = user.email || '';
document.getElementById('username').value = user.username || '';
// Update character count
bioCharCount.textContent = (user.bio || '').length;
// Show avatar preview if URL exists
if (user.avatarUrl) {
avatarPreview.src = user.avatarUrl;
avatarPreviewContainer.style.display = 'block';
}
}
});
</script>
</th:block>
</body>
</html>

View file

@ -0,0 +1,327 @@
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<head>
<title>User Profile</title>
</head>
<body>
<div layout:fragment="content">
<!-- Loading Indicator -->
<div id="loadingIndicator" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2 text-muted">Loading profile...</p>
</div>
<!-- Error Alert -->
<div id="errorAlert" class="alert alert-danger d-none" role="alert">
<i class="bi bi-exclamation-triangle-fill"></i>
<span id="errorMessage"></span>
</div>
<!-- Profile Content -->
<div id="profileContent" class="d-none">
<!-- Profile Header -->
<div class="card mb-4">
<div class="card-body">
<div class="row">
<div class="col-md-2 text-center">
<!-- Avatar -->
<div id="avatarContainer" class="mb-3">
<img id="avatarImage" src="" alt="Avatar" class="rounded-circle d-none" width="120" height="120">
<div id="avatarPlaceholder" class="avatar-placeholder-large rounded-circle mx-auto">
<i class="bi bi-person-circle"></i>
</div>
</div>
</div>
<div class="col-md-10">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h2 id="displayName" class="mb-1"></h2>
<p class="text-muted mb-2">
<span id="username"></span>
</p>
<p id="bio" class="mb-3"></p>
</div>
<div id="followButtonContainer" class="d-none">
<button class="btn btn-primary" id="followBtn">
<i class="bi bi-person-plus"></i> Follow
</button>
</div>
</div>
<!-- Stats -->
<div class="row text-center">
<div class="col-4">
<div class="stat-card">
<div class="stat-value" id="activitiesCount">0</div>
<div class="stat-label">Activities</div>
</div>
</div>
<div class="col-4">
<div class="stat-card">
<div class="stat-value" id="followersCount">0</div>
<div class="stat-label">Followers</div>
</div>
</div>
<div class="col-4">
<div class="stat-card">
<div class="stat-value" id="followingCount">0</div>
<div class="stat-label">Following</div>
</div>
</div>
</div>
<!-- Additional Info -->
<div class="mt-3 text-muted small">
<i class="bi bi-calendar"></i> Joined <span id="joinedDate"></span>
</div>
</div>
</div>
</div>
</div>
<!-- Public Activities -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-list-task"></i> Public Activities
</h5>
</div>
<div class="card-body">
<!-- Loading Indicator for Activities -->
<div id="activitiesLoading" class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<!-- Activities List -->
<div id="activitiesList" class="d-none">
<!-- Will be populated by JavaScript -->
</div>
<!-- Empty State -->
<div id="activitiesEmpty" class="text-center py-4 d-none">
<i class="bi bi-inbox" style="font-size: 3rem; color: #d1d5db;"></i>
<p class="text-muted mt-2">No public activities yet</p>
</div>
<!-- Pagination -->
<nav id="pagination" aria-label="Activities pagination" class="mt-3 d-none">
<ul class="pagination justify-content-center" id="paginationList">
<!-- Will be populated by JavaScript -->
</ul>
</nav>
</div>
</div>
</div>
</div>
<!-- Custom Scripts -->
<th:block layout:fragment="scripts">
<script th:inline="javascript">
const targetUsername = /*[[${username}]]*/ '';
document.addEventListener('DOMContentLoaded', function() {
loadProfile();
function loadProfile() {
// For now, we'll fetch from the user API endpoint
// In the future, this should use /api/users/{username}
fetch(`/api/users/${targetUsername}`)
.then(response => {
if (!response.ok) {
throw new Error('User not found');
}
return response.json();
})
.then(user => {
renderProfile(user);
loadPublicActivities(user.id);
})
.catch(error => {
console.error('Error loading profile:', error);
document.getElementById('loadingIndicator').classList.add('d-none');
document.getElementById('errorMessage').textContent = 'User not found or profile could not be loaded.';
document.getElementById('errorAlert').classList.remove('d-none');
});
}
function renderProfile(user) {
// Hide loading, show content
document.getElementById('loadingIndicator').classList.add('d-none');
document.getElementById('profileContent').classList.remove('d-none');
// Display name
document.getElementById('displayName').textContent = user.displayName || user.username;
// Username
document.getElementById('username').textContent = '@' + user.username;
// Bio
const bioElement = document.getElementById('bio');
if (user.bio) {
bioElement.textContent = user.bio;
} else {
bioElement.innerHTML = '<span class="text-muted">No bio</span>';
}
// Avatar
if (user.avatarUrl) {
document.getElementById('avatarImage').src = user.avatarUrl;
document.getElementById('avatarImage').classList.remove('d-none');
document.getElementById('avatarPlaceholder').classList.add('d-none');
}
// Joined date
const joinedDate = new Date(user.createdAt);
document.getElementById('joinedDate').textContent = joinedDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
// Show follow button if viewing another user's profile
// TODO: implement follow functionality
}
let currentPage = 0;
async function loadPublicActivities(userId) {
try {
const response = await fetch(`/api/activities/user/${targetUsername}?page=${currentPage}&size=10`);
if (response.ok) {
const data = await response.json();
document.getElementById('activitiesLoading').classList.add('d-none');
// Update count
document.getElementById('activitiesCount').textContent = data.totalElements || 0;
if (data.content && data.content.length > 0) {
renderActivities(data.content);
renderPagination(data);
document.getElementById('activitiesList').classList.remove('d-none');
if (data.totalPages > 1) {
document.getElementById('pagination').classList.remove('d-none');
}
} else {
document.getElementById('activitiesEmpty').classList.remove('d-none');
}
} else {
throw new Error('Failed to load activities');
}
} catch (error) {
console.error('Error loading activities:', error);
document.getElementById('activitiesLoading').classList.add('d-none');
document.getElementById('activitiesEmpty').classList.remove('d-none');
}
}
function renderActivities(activities) {
const list = document.getElementById('activitiesList');
list.innerHTML = activities.map(activity => `
<div class="activity-item mb-3 pb-3 border-bottom">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<h6 class="mb-1">
<a href="/activities/${activity.id}" class="text-decoration-none">
${escapeHtml(activity.title || 'Untitled Activity')}
</a>
</h6>
<p class="text-muted small mb-2">
<span class="activity-type-badge activity-type-${activity.activityType.toLowerCase()}">
${activity.activityType}
</span>
<span class="ms-2">
<i class="bi bi-calendar"></i>
${new Date(activity.startedAt).toLocaleDateString()}
</span>
</p>
<div class="d-flex gap-3 text-muted small">
<span><i class="bi bi-arrow-left-right"></i> ${formatDistance(activity.totalDistance)}</span>
<span><i class="bi bi-clock"></i> ${formatDuration(activity.totalDuration)}</span>
${activity.elevationGain ? `<span><i class="bi bi-arrow-up"></i> ${Math.round(activity.elevationGain)}m</span>` : ''}
</div>
</div>
</div>
</div>
`).join('');
}
function renderPagination(data) {
const paginationList = document.getElementById('paginationList');
let html = '';
// Previous button
html += `
<li class="page-item ${data.first ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="changePage(${data.number - 1}); return false;">
<i class="bi bi-chevron-left"></i>
</a>
</li>
`;
// Page numbers
const startPage = Math.max(0, data.number - 2);
const endPage = Math.min(data.totalPages - 1, data.number + 2);
for (let i = startPage; i <= endPage; i++) {
html += `
<li class="page-item ${i === data.number ? 'active' : ''}">
<a class="page-link" href="#" onclick="changePage(${i}); return false;">${i + 1}</a>
</li>
`;
}
// Next button
html += `
<li class="page-item ${data.last ? 'disabled' : ''}">
<a class="page-link" href="#" onclick="changePage(${data.number + 1}); return false;">
<i class="bi bi-chevron-right"></i>
</a>
</li>
`;
paginationList.innerHTML = html;
}
window.changePage = function(page) {
currentPage = page;
loadPublicActivities();
window.scrollTo({ top: 0, behavior: 'smooth' });
};
function formatDistance(meters) {
if (!meters) return 'N/A';
if (meters >= 1000) {
return (meters / 1000).toFixed(1) + ' km';
}
return Math.round(meters) + ' m';
}
function formatDuration(seconds) {
if (!seconds) return 'N/A';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return hours + 'h ' + minutes + 'm';
}
return minutes + 'm';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
});
</script>
</th:block>
</body>
</html>

View file

@ -0,0 +1,290 @@
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<head>
<title>My Profile</title>
</head>
<body>
<div layout:fragment="content">
<!-- Loading Indicator -->
<div id="loadingIndicator" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2 text-muted">Loading profile...</p>
</div>
<!-- Error Alert -->
<div id="errorAlert" class="alert alert-danger d-none" role="alert">
<i class="bi bi-exclamation-triangle-fill"></i>
<span id="errorMessage"></span>
</div>
<!-- Profile Content -->
<div id="profileContent" class="d-none">
<!-- Profile Header -->
<div class="card mb-4">
<div class="card-body">
<div class="row">
<div class="col-md-2 text-center">
<!-- Avatar -->
<div id="avatarContainer" class="mb-3">
<img id="avatarImage" src="" alt="Avatar" class="rounded-circle d-none" width="120" height="120">
<div id="avatarPlaceholder" class="avatar-placeholder-large rounded-circle mx-auto">
<i class="bi bi-person-circle"></i>
</div>
</div>
</div>
<div class="col-md-10">
<div class="d-flex justify-content-between align-items-start mb-3">
<div>
<h2 id="displayName" class="mb-1"></h2>
<p class="text-muted mb-2">
<span id="username"></span>
</p>
<p id="bio" class="mb-3"></p>
</div>
<div>
<a th:href="@{/profile/edit}" class="btn btn-outline-primary">
<i class="bi bi-pencil"></i> Edit Profile
</a>
</div>
</div>
<!-- Stats -->
<div class="row text-center">
<div class="col-4">
<div class="stat-card">
<div class="stat-value" id="activitiesCount">0</div>
<div class="stat-label">Activities</div>
</div>
</div>
<div class="col-4">
<div class="stat-card">
<div class="stat-value" id="followersCount">0</div>
<div class="stat-label">Followers</div>
</div>
</div>
<div class="col-4">
<div class="stat-card">
<div class="stat-value" id="followingCount">0</div>
<div class="stat-label">Following</div>
</div>
</div>
</div>
<!-- Additional Info -->
<div class="mt-3 text-muted small">
<i class="bi bi-envelope"></i> <span id="email"></span>
<span class="ms-3">
<i class="bi bi-calendar"></i> Joined <span id="joinedDate"></span>
</span>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Activities -->
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="bi bi-list-task"></i> Recent Activities
</h5>
</div>
<div class="card-body">
<!-- Loading Indicator for Activities -->
<div id="activitiesLoading" class="text-center py-3">
<div class="spinner-border spinner-border-sm text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
</div>
<!-- Activities List -->
<div id="activitiesList" class="d-none">
<!-- Will be populated by JavaScript -->
</div>
<!-- Empty State -->
<div id="activitiesEmpty" class="text-center py-4 d-none">
<i class="bi bi-inbox" style="font-size: 3rem; color: #d1d5db;"></i>
<p class="text-muted mt-2">No activities yet</p>
<a th:href="@{/activities/upload}" class="btn btn-sm btn-primary">
<i class="bi bi-cloud-upload"></i> Upload Activity
</a>
</div>
<!-- View All Link -->
<div id="viewAllActivities" class="text-center mt-3 d-none">
<a th:href="@{/activities}" class="btn btn-sm btn-outline-primary">
View All Activities
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Custom Scripts -->
<th:block layout:fragment="scripts">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
// Redirect to login if not authenticated
if (!FitPubAuth.isAuthenticated()) {
window.location.href = '/login';
return;
}
loadProfile();
async function loadProfile() {
try {
// Fetch user profile
const response = await FitPubAuth.authenticatedFetch('/api/users/me');
if (response.ok) {
const user = await response.json();
renderProfile(user);
loadRecentActivities();
} else {
throw new Error('Failed to load profile');
}
} catch (error) {
console.error('Error loading profile:', error);
document.getElementById('loadingIndicator').classList.add('d-none');
document.getElementById('errorMessage').textContent = 'Failed to load profile. Please try again.';
document.getElementById('errorAlert').classList.remove('d-none');
}
}
function renderProfile(user) {
// Hide loading, show content
document.getElementById('loadingIndicator').classList.add('d-none');
document.getElementById('profileContent').classList.remove('d-none');
// Display name
document.getElementById('displayName').textContent = user.displayName || user.username;
// Username
document.getElementById('username').textContent = '@' + user.username;
// Bio
const bioElement = document.getElementById('bio');
if (user.bio) {
bioElement.textContent = user.bio;
} else {
bioElement.innerHTML = '<span class="text-muted">No bio yet. <a href="/profile/edit">Add one?</a></span>';
}
// Avatar
if (user.avatarUrl) {
document.getElementById('avatarImage').src = user.avatarUrl;
document.getElementById('avatarImage').classList.remove('d-none');
document.getElementById('avatarPlaceholder').classList.add('d-none');
}
// Email
document.getElementById('email').textContent = user.email;
// Joined date
const joinedDate = new Date(user.createdAt);
document.getElementById('joinedDate').textContent = joinedDate.toLocaleDateString('en-US', { month: 'long', year: 'numeric' });
// Stats (activities count will be loaded separately)
// Followers/Following counts TODO: implement when federation is ready
}
async function loadRecentActivities() {
try {
const response = await FitPubAuth.authenticatedFetch('/api/activities?page=0&size=5');
if (response.ok) {
const data = await response.json();
document.getElementById('activitiesLoading').classList.add('d-none');
// Update activities count
document.getElementById('activitiesCount').textContent = data.totalElements || 0;
if (data.content && data.content.length > 0) {
renderActivities(data.content);
document.getElementById('activitiesList').classList.remove('d-none');
if (data.totalElements > 5) {
document.getElementById('viewAllActivities').classList.remove('d-none');
}
} else {
document.getElementById('activitiesEmpty').classList.remove('d-none');
}
}
} catch (error) {
console.error('Error loading activities:', error);
document.getElementById('activitiesLoading').classList.add('d-none');
document.getElementById('activitiesEmpty').classList.remove('d-none');
}
}
function renderActivities(activities) {
const list = document.getElementById('activitiesList');
list.innerHTML = activities.map(activity => `
<div class="activity-item mb-3 pb-3 border-bottom">
<div class="d-flex justify-content-between align-items-start">
<div class="flex-grow-1">
<h6 class="mb-1">
<a href="/activities/${activity.id}" class="text-decoration-none">
${escapeHtml(activity.title || 'Untitled Activity')}
</a>
</h6>
<p class="text-muted small mb-2">
<span class="activity-type-badge activity-type-${activity.activityType.toLowerCase()}">
${activity.activityType}
</span>
<span class="ms-2">
<i class="bi bi-calendar"></i>
${new Date(activity.startedAt).toLocaleDateString()}
</span>
</p>
<div class="d-flex gap-3 text-muted small">
<span><i class="bi bi-arrow-left-right"></i> ${formatDistance(activity.totalDistance)}</span>
<span><i class="bi bi-clock"></i> ${formatDuration(activity.totalDuration)}</span>
${activity.elevationGain ? `<span><i class="bi bi-arrow-up"></i> ${Math.round(activity.elevationGain)}m</span>` : ''}
</div>
</div>
</div>
</div>
`).join('');
}
function formatDistance(meters) {
if (!meters) return 'N/A';
if (meters >= 1000) {
return (meters / 1000).toFixed(1) + ' km';
}
return Math.round(meters) + ' m';
}
function formatDuration(seconds) {
if (!seconds) return 'N/A';
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
if (hours > 0) {
return hours + 'h ' + minutes + 'm';
}
return minutes + 'm';
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
});
</script>
</th:block>
</body>
</html>

View file

@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<head>
<title>Settings</title>
</head>
<body>
<div layout:fragment="content">
<div class="row justify-content-center">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h4 class="mb-0">
<i class="bi bi-gear"></i> Settings
</h4>
</div>
<div class="card-body" id="settingsContent">
<p class="text-muted">Settings page - Coming soon!</p>
<div class="list-group mt-4">
<a href="/profile/edit" class="list-group-item list-group-item-action">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">
<i class="bi bi-person"></i> Edit Profile
</h5>
<small><i class="bi bi-chevron-right"></i></small>
</div>
<p class="mb-1">Update your display name, bio, and avatar</p>
</a>
<div class="list-group-item list-group-item-action disabled">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">
<i class="bi bi-shield-lock"></i> Privacy Settings
</h5>
<small class="text-muted">Coming soon</small>
</div>
<p class="mb-1 text-muted">Manage your privacy and data preferences</p>
</div>
<div class="list-group-item list-group-item-action disabled">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">
<i class="bi bi-key"></i> Change Password
</h5>
<small class="text-muted">Coming soon</small>
</div>
<p class="mb-1 text-muted">Update your account password</p>
</div>
<div class="list-group-item list-group-item-action disabled">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">
<i class="bi bi-bell"></i> Notifications
</h5>
<small class="text-muted">Coming soon</small>
</div>
<p class="mb-1 text-muted">Configure notification preferences</p>
</div>
<div class="list-group-item list-group-item-action disabled">
<div class="d-flex w-100 justify-content-between">
<h5 class="mb-1">
<i class="bi bi-download"></i> Export Data
</h5>
<small class="text-muted">Coming soon</small>
</div>
<p class="mb-1 text-muted">Download your activities and data</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Custom Scripts -->
<th:block layout:fragment="scripts">
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
// Redirect to login if not authenticated
if (!FitPubAuth.isAuthenticated()) {
window.location.href = '/login';
return;
}
});
</script>
</th:block>
</body>
</html>

View file

@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<head>
<title>Federated Timeline</title>
</head>
<body>
<div layout:fragment="content">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>
<i class="bi bi-people text-primary"></i>
Following Timeline
</h2>
<div class="btn-group" role="group" aria-label="Timeline views">
<a th:href="@{/timeline}" class="btn btn-outline-primary">
<i class="bi bi-globe"></i> Public
</a>
<a th:href="@{/timeline/federated}" class="btn btn-primary active">
<i class="bi bi-people"></i> Following
</a>
<a th:href="@{/timeline/user}" class="btn btn-outline-primary">
<i class="bi bi-person"></i> My Timeline
</a>
</div>
</div>
<p class="text-muted mb-4">
Activities from athletes you follow
</p>
<!-- Loading Indicator -->
<div id="loadingIndicator" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2 text-muted">Loading timeline...</p>
</div>
<!-- Error Alert -->
<div id="errorAlert" class="alert alert-danger d-none" role="alert">
<i class="bi bi-exclamation-triangle-fill"></i>
<span id="errorMessage"></span>
</div>
<!-- Timeline Activities -->
<div id="timelineList" class="d-none">
<!-- Will be populated by JavaScript -->
</div>
<!-- Empty State -->
<div id="emptyState" class="text-center py-5 d-none">
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
<h4 class="mt-3">No Activities Yet</h4>
<p class="text-muted">Follow other athletes to see their activities here!</p>
<a th:href="@{/timeline}" class="btn btn-primary mt-3">
<i class="bi bi-globe"></i> Explore Public Timeline
</a>
</div>
<!-- Pagination -->
<nav id="pagination" aria-label="Timeline pagination" class="mt-4 d-none">
<ul class="pagination justify-content-center" id="paginationList">
<!-- Will be populated by JavaScript -->
</ul>
</nav>
</div>
</div>
</div>
<!-- Custom Scripts -->
<th:block layout:fragment="scripts">
<script th:src="@{/js/timeline.js}"></script>
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
// Redirect to login if not authenticated
if (!FitPubAuth.isAuthenticated()) {
window.location.href = '/login';
return;
}
// Initialize timeline
FitPubTimeline.init('federated');
});
</script>
</th:block>
</body>
</html>

View file

@ -0,0 +1,94 @@
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<head>
<title>Public Timeline</title>
</head>
<body>
<div layout:fragment="content">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>
<i class="bi bi-globe text-primary"></i>
Public Timeline
</h2>
<div class="btn-group" role="group" aria-label="Timeline views">
<a th:href="@{/timeline}" class="btn btn-primary active">
<i class="bi bi-globe"></i> Public
</a>
<a th:href="@{/timeline/federated}" class="btn btn-outline-primary" id="federatedLink" style="display: none;">
<i class="bi bi-people"></i> Following
</a>
<a th:href="@{/timeline/user}" class="btn btn-outline-primary" id="userTimelineLink" style="display: none;">
<i class="bi bi-person"></i> My Timeline
</a>
</div>
</div>
<p class="text-muted mb-4">
Discover public fitness activities from the FitPub community
</p>
<!-- Loading Indicator -->
<div id="loadingIndicator" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2 text-muted">Loading timeline...</p>
</div>
<!-- Error Alert -->
<div id="errorAlert" class="alert alert-danger d-none" role="alert">
<i class="bi bi-exclamation-triangle-fill"></i>
<span id="errorMessage"></span>
</div>
<!-- Timeline Activities -->
<div id="timelineList" class="d-none">
<!-- Will be populated by JavaScript -->
</div>
<!-- Empty State -->
<div id="emptyState" class="text-center py-5 d-none">
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
<h4 class="mt-3">No Activities Yet</h4>
<p class="text-muted">Be the first to share your fitness activities!</p>
<a th:href="@{/activities/upload}" class="btn btn-primary mt-3" id="uploadLinkEmpty" style="display: none;">
<i class="bi bi-cloud-upload"></i> Upload Activity
</a>
</div>
<!-- Pagination -->
<nav id="pagination" aria-label="Timeline pagination" class="mt-4 d-none">
<ul class="pagination justify-content-center" id="paginationList">
<!-- Will be populated by JavaScript -->
</ul>
</nav>
</div>
</div>
</div>
<!-- Custom Scripts -->
<th:block layout:fragment="scripts">
<script th:src="@{/js/timeline.js}"></script>
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
// Show federated/user timeline links if logged in
if (FitPubAuth.isAuthenticated()) {
document.getElementById('federatedLink').style.display = 'inline-block';
document.getElementById('userTimelineLink').style.display = 'inline-block';
document.getElementById('uploadLinkEmpty').style.display = 'inline-block';
}
// Initialize timeline
FitPubTimeline.init('public');
});
</script>
</th:block>
</body>
</html>

View file

@ -0,0 +1,93 @@
<!DOCTYPE html>
<html lang="en"
xmlns:th="http://www.thymeleaf.org"
xmlns:layout="http://www.ultraq.net.nz/thymeleaf/layout"
layout:decorate="~{layout}">
<head>
<title>My Timeline</title>
</head>
<body>
<div layout:fragment="content">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h2>
<i class="bi bi-person text-primary"></i>
My Timeline
</h2>
<div class="btn-group" role="group" aria-label="Timeline views">
<a th:href="@{/timeline}" class="btn btn-outline-primary">
<i class="bi bi-globe"></i> Public
</a>
<a th:href="@{/timeline/federated}" class="btn btn-outline-primary">
<i class="bi bi-people"></i> Following
</a>
<a th:href="@{/timeline/user}" class="btn btn-primary active">
<i class="bi bi-person"></i> My Timeline
</a>
</div>
</div>
<p class="text-muted mb-4">
Your fitness activities
</p>
<!-- Loading Indicator -->
<div id="loadingIndicator" class="text-center py-5">
<div class="spinner-border text-primary" role="status">
<span class="visually-hidden">Loading...</span>
</div>
<p class="mt-2 text-muted">Loading timeline...</p>
</div>
<!-- Error Alert -->
<div id="errorAlert" class="alert alert-danger d-none" role="alert">
<i class="bi bi-exclamation-triangle-fill"></i>
<span id="errorMessage"></span>
</div>
<!-- Timeline Activities -->
<div id="timelineList" class="d-none">
<!-- Will be populated by JavaScript -->
</div>
<!-- Empty State -->
<div id="emptyState" class="text-center py-5 d-none">
<i class="bi bi-inbox" style="font-size: 4rem; color: #d1d5db;"></i>
<h4 class="mt-3">No Activities Yet</h4>
<p class="text-muted">Upload your first FIT file to get started!</p>
<a th:href="@{/activities/upload}" class="btn btn-primary mt-3">
<i class="bi bi-cloud-upload"></i> Upload Activity
</a>
</div>
<!-- Pagination -->
<nav id="pagination" aria-label="Timeline pagination" class="mt-4 d-none">
<ul class="pagination justify-content-center" id="paginationList">
<!-- Will be populated by JavaScript -->
</ul>
</nav>
</div>
</div>
</div>
<!-- Custom Scripts -->
<th:block layout:fragment="scripts">
<script th:src="@{/js/timeline.js}"></script>
<script th:inline="javascript">
document.addEventListener('DOMContentLoaded', function() {
// Redirect to login if not authenticated
if (!FitPubAuth.isAuthenticated()) {
window.location.href = '/login';
return;
}
// Initialize timeline
FitPubTimeline.init('user');
});
</script>
</th:block>
</body>
</html>