More vibin
This commit is contained in:
parent
1901daf5ce
commit
c1729a629d
47 changed files with 5754 additions and 41 deletions
45
.dockerignore
Normal file
45
.dockerignore
Normal file
|
|
@ -0,0 +1,45 @@
|
||||||
|
# Build artifacts
|
||||||
|
target/
|
||||||
|
*.jar
|
||||||
|
*.war
|
||||||
|
*.ear
|
||||||
|
|
||||||
|
# IDE files
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
.vscode/
|
||||||
|
.classpath
|
||||||
|
.project
|
||||||
|
.settings/
|
||||||
|
|
||||||
|
# Git
|
||||||
|
.git/
|
||||||
|
.gitignore
|
||||||
|
|
||||||
|
# Docker
|
||||||
|
Dockerfile
|
||||||
|
docker-compose.yml
|
||||||
|
.dockerignore
|
||||||
|
|
||||||
|
# Environment files
|
||||||
|
.env
|
||||||
|
.env.*
|
||||||
|
|
||||||
|
# Logs
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# OS files
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# Documentation
|
||||||
|
README.md
|
||||||
|
CLAUDE.md
|
||||||
|
*.md
|
||||||
|
|
||||||
|
# Test files
|
||||||
|
src/test/
|
||||||
|
|
||||||
|
# Maven wrapper
|
||||||
|
.mvn/wrapper/maven-wrapper.jar
|
||||||
37
.env.example
Normal file
37
.env.example
Normal file
|
|
@ -0,0 +1,37 @@
|
||||||
|
# PostgreSQL Database Configuration
|
||||||
|
POSTGRES_DB=fitpub
|
||||||
|
POSTGRES_USER=fitpub
|
||||||
|
POSTGRES_PASSWORD=change_me_in_production
|
||||||
|
POSTGRES_PORT=5432
|
||||||
|
|
||||||
|
# Application Configuration
|
||||||
|
APP_PORT=8080
|
||||||
|
SPRING_PROFILES_ACTIVE=prod
|
||||||
|
|
||||||
|
# Domain and URL Configuration
|
||||||
|
APP_DOMAIN=example.com
|
||||||
|
APP_BASE_URL=https://example.com
|
||||||
|
|
||||||
|
# Security Configuration
|
||||||
|
# Generate a secure random string for JWT_SECRET in production
|
||||||
|
# Example: openssl rand -base64 64
|
||||||
|
JWT_SECRET=change_me_to_a_secure_random_string_in_production
|
||||||
|
JWT_EXPIRATION_MS=86400000
|
||||||
|
|
||||||
|
# ActivityPub Configuration
|
||||||
|
ACTIVITYPUB_ENABLED=true
|
||||||
|
|
||||||
|
# File Upload Configuration
|
||||||
|
FILE_UPLOAD_MAX_SIZE=50MB
|
||||||
|
FILE_UPLOAD_DIR=/app/uploads
|
||||||
|
|
||||||
|
# Logging Configuration
|
||||||
|
LOG_LEVEL_ROOT=INFO
|
||||||
|
LOG_LEVEL_APP=INFO
|
||||||
|
LOG_LEVEL_SPRING=INFO
|
||||||
|
LOG_LEVEL_HIBERNATE=WARN
|
||||||
|
LOG_LEVEL_FLYWAY=INFO
|
||||||
|
|
||||||
|
# JPA/Hibernate Configuration
|
||||||
|
JPA_SHOW_SQL=false
|
||||||
|
JPA_FORMAT_SQL=false
|
||||||
9
.gitignore
vendored
9
.gitignore
vendored
|
|
@ -37,3 +37,12 @@ build/
|
||||||
|
|
||||||
### Mac OS ###
|
### Mac OS ###
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
|
||||||
|
### Environment Variables ###
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.production
|
||||||
|
|
||||||
|
### Application Files ###
|
||||||
|
uploads/
|
||||||
|
logs/
|
||||||
172
CLAUDE.md
172
CLAUDE.md
|
|
@ -561,50 +561,158 @@ For ActivityPub federated posts and thumbnails:
|
||||||
- [x] Account identifier parsing (acct:user@domain)
|
- [x] Account identifier parsing (acct:user@domain)
|
||||||
- [x] Tested with valid and invalid requests
|
- [x] Tested with valid and invalid requests
|
||||||
|
|
||||||
**ActivityPub Collections**
|
**ActivityPub Collections** ✅
|
||||||
- [ ] GET /users/{username}/inbox - Inbox endpoint
|
- [x] POST /users/{username}/inbox - Inbox endpoint (accepts activities with 202 Accepted)
|
||||||
- [ ] GET /users/{username}/outbox - Outbox endpoint
|
- [x] GET /users/{username}/outbox - Outbox endpoint (returns empty OrderedCollection)
|
||||||
- [ ] GET /users/{username}/followers - Followers collection
|
- [x] GET /users/{username}/followers - Followers collection (returns empty OrderedCollection)
|
||||||
- [ ] GET /users/{username}/following - Following collection
|
- [x] GET /users/{username}/following - Following collection (returns empty OrderedCollection)
|
||||||
- [ ] OrderedCollection model classes
|
- [x] OrderedCollection model classes
|
||||||
- [ ] Collection pagination support
|
- [x] Basic collection structure (TODOs exist for populating with actual data)
|
||||||
|
- [x] All endpoints tested and working
|
||||||
|
|
||||||
**Basic Federation**
|
**Basic Federation** ✅
|
||||||
- [ ] Federation service for outbound activities
|
- [x] Federation service for outbound activities (FederationService.java)
|
||||||
- [ ] HTTP signature signing for outbound requests
|
- [x] HTTP signature signing for outbound requests (signRequest method in HttpSignatureValidator)
|
||||||
- [ ] HTTP signature verification for inbound requests
|
- [x] HTTP signature verification for inbound requests (validate method already existed)
|
||||||
- [ ] Create activity - Post new workout
|
- [x] Follow activity - Remote user follows local user (InboxProcessor)
|
||||||
- [ ] Follow activity - Remote user follows local user
|
- [x] Accept activity - Accept follow requests (FederationService.sendAcceptActivity)
|
||||||
- [ ] Accept activity - Accept follow requests
|
- [x] Undo activity - Unfollow support (InboxProcessor)
|
||||||
- [ ] Follow entity and repository
|
- [x] Follow entity and repository (Follow.java, FollowRepository.java)
|
||||||
- [ ] Remote actor entity and repository
|
- [x] Remote actor entity and repository (RemoteActor.java, RemoteActorRepository.java)
|
||||||
- [ ] Inbox processor for incoming activities
|
- [x] Inbox processor for incoming activities (InboxProcessor.java)
|
||||||
|
- [x] Remote actor fetching and caching
|
||||||
|
- [x] Follower inbox collection for activity distribution
|
||||||
|
|
||||||
**Public Timeline**
|
**Public Timeline** ✅
|
||||||
- [ ] Timeline service
|
- [x] Timeline service (TimelineService.java)
|
||||||
- [ ] GET /api/timeline - Federated timeline
|
- [x] Timeline DTOs for response (TimelineActivityDTO.java with ActivityMetricsSummary)
|
||||||
- [ ] Merge local and remote activities
|
- [x] GET /api/timeline/federated - Federated timeline for authenticated user
|
||||||
- [ ] Timeline filtering and pagination
|
- [x] GET /api/timeline/public - Public timeline (all public activities)
|
||||||
- [ ] Activity visibility enforcement
|
- [x] GET /api/timeline/user - User's own timeline
|
||||||
|
- [x] Timeline filtering and pagination (Spring Data Pageable)
|
||||||
|
- [x] Activity visibility enforcement (PUBLIC, FOLLOWERS)
|
||||||
|
- [x] Repository methods for timeline queries (findByUserIdInAndVisibilityInOrderByStartedAtDesc, findByVisibilityOrderByStartedAtDesc)
|
||||||
|
|
||||||
**Database Migrations**
|
**Database Migrations** ✅
|
||||||
- [ ] Flyway setup
|
- [x] Flyway setup and configuration
|
||||||
- [ ] Initial schema migration (users table)
|
- [x] V1: Enable PostGIS extension
|
||||||
- [ ] Activities table migration
|
- [x] V2: Users table with indexes (username, email, created_at)
|
||||||
- [ ] Activity metrics table migration
|
- [x] V3: Activities table with geospatial support (GIST index on simplified_track, GIN index on track_points_json)
|
||||||
- [ ] Follows table migration
|
- [x] V4: Activity metrics table with one-to-one relationship
|
||||||
- [ ] Remote actors table migration
|
- [x] V5: Follows table for federation (follower_id, following_actor_uri, status)
|
||||||
- [ ] Indexes for performance (user lookups, activity queries)
|
- [x] V6: Remote actors table for ActivityPub federation cache
|
||||||
- [ ] PostGIS spatial indexes
|
- [x] All indexes for performance (user lookups, activity queries, spatial queries)
|
||||||
|
- [x] Changed Hibernate ddl-auto from 'update' to 'validate'
|
||||||
|
|
||||||
|
**Frontend Infrastructure** ✅
|
||||||
|
- [x] Choose frontend approach (Thymeleaf + HTMX for server-side rendering with dynamic interactions)
|
||||||
|
- [x] Static asset structure (css/, js/, img/ directories)
|
||||||
|
- [x] HTMX dependency setup (via CDN in layout.html)
|
||||||
|
- [x] Leaflet.js dependency setup (via CDN in layout.html)
|
||||||
|
- [x] Chart.js dependency setup (via CDN in layout.html)
|
||||||
|
- [x] CSS framework setup (Bootstrap 5.3.2 via CDN + Bootstrap Icons)
|
||||||
|
- [x] Base Thymeleaf layout template with navigation (layout.html)
|
||||||
|
- [x] Responsive mobile design foundation (Bootstrap grid + custom CSS)
|
||||||
|
- [x] Custom CSS with FitPub theme (fitpub.css)
|
||||||
|
- [x] Custom JavaScript utilities (fitpub.js with map/chart helpers)
|
||||||
|
- [x] Thymeleaf dependencies added to pom.xml
|
||||||
|
- [x] Home page template (index.html)
|
||||||
|
- [x] Home controller for routing
|
||||||
|
|
||||||
|
**Authentication UI** ✅
|
||||||
|
- [x] User registration page/form (auth/register.html)
|
||||||
|
- [x] Login page/form (auth/login.html)
|
||||||
|
- [x] JWT token storage (localStorage in auth.js)
|
||||||
|
- [x] Authentication state management (FitPubAuth object in auth.js)
|
||||||
|
- [x] Protected route handling (SecurityConfig.java + client-side checks)
|
||||||
|
- [x] Logout functionality (client-side token removal + server endpoint)
|
||||||
|
- [x] Session expiration handling (JWT expiration check + warning)
|
||||||
|
- [x] Login/registration error display (alert components in forms)
|
||||||
|
- [x] Authentication view controller (AuthViewController.java)
|
||||||
|
- [x] Dynamic navigation menu (shows/hides based on auth status)
|
||||||
|
- [x] Form validation with Bootstrap
|
||||||
|
- [x] Loading states for submit buttons
|
||||||
|
- [x] Password confirmation validation
|
||||||
|
- [x] Authenticated API fetch helper (authenticatedFetch in auth.js)
|
||||||
|
|
||||||
|
**Activity Upload & Management UI** ✅
|
||||||
|
- [x] FIT file upload form with drag-and-drop
|
||||||
|
- [x] Upload progress indicator
|
||||||
|
- [x] Activity metadata form (title, description, visibility)
|
||||||
|
- [x] Activity list view (user's own activities)
|
||||||
|
- [x] Activity pagination controls
|
||||||
|
- [x] Activity delete confirmation dialog
|
||||||
|
- [x] Activity edit form
|
||||||
|
- [x] File upload validation and error messages
|
||||||
|
|
||||||
|
**Map Rendering & Visualization** ✅
|
||||||
|
- [x] Leaflet.js map initialization
|
||||||
|
- [x] OpenStreetMap tile layer integration
|
||||||
|
- [x] GeoJSON track rendering on map
|
||||||
|
- [x] Start/finish markers (green/red)
|
||||||
|
- [x] Map bounds auto-fitting to track
|
||||||
|
- [x] Track click handler for point-in-time metrics
|
||||||
|
- [x] Map loading states and error handling
|
||||||
|
- [x] Responsive map sizing
|
||||||
|
|
||||||
|
**Activity Detail Page**
|
||||||
|
- [ ] Activity metadata display (title, description, date, type)
|
||||||
|
- [ ] Interactive map with GPS track
|
||||||
|
- [ ] Activity metrics display (distance, duration, pace, elevation)
|
||||||
|
- [ ] Elevation profile chart (Chart.js)
|
||||||
|
- [ ] Heart rate chart (if available)
|
||||||
|
- [ ] Speed/pace chart
|
||||||
|
- [ ] Activity statistics summary cards
|
||||||
|
- [ ] Visibility indicator (Public/Followers/Private)
|
||||||
|
|
||||||
|
**Timeline & Social Features UI**
|
||||||
|
- [ ] Public timeline page
|
||||||
|
- [ ] Federated timeline page (following feed)
|
||||||
|
- [ ] User timeline page (own activities)
|
||||||
|
- [ ] Timeline activity cards with preview maps
|
||||||
|
- [ ] Activity card metrics summary
|
||||||
|
- [ ] Pagination for timeline
|
||||||
|
- [ ] Empty state messages
|
||||||
|
- [ ] Loading states for timelines
|
||||||
|
|
||||||
|
**User Profile UI**
|
||||||
|
- [ ] Public user profile page
|
||||||
|
- [ ] User profile display (avatar, bio, display name)
|
||||||
|
- [ ] User's activity list on profile
|
||||||
|
- [ ] Follower/following counts
|
||||||
|
- [ ] Profile edit page
|
||||||
|
- [ ] Avatar upload (optional for MVP)
|
||||||
|
- [ ] Profile settings form
|
||||||
|
|
||||||
|
**Navigation & Layout**
|
||||||
|
- [ ] Top navigation bar with logo
|
||||||
|
- [ ] Navigation links (Timeline, My Activities, Upload, Profile)
|
||||||
|
- [ ] User menu dropdown (Profile, Settings, Logout)
|
||||||
|
- [ ] Breadcrumb navigation
|
||||||
|
- [ ] Footer with app info
|
||||||
|
- [ ] Mobile hamburger menu
|
||||||
|
- [ ] Active route highlighting
|
||||||
|
|
||||||
|
**Error Handling & User Feedback**
|
||||||
|
- [ ] Global error boundary/handler
|
||||||
|
- [ ] API error message display
|
||||||
|
- [ ] Success notifications/toasts
|
||||||
|
- [ ] Form validation error display
|
||||||
|
- [ ] 404 Not Found page
|
||||||
|
- [ ] 403 Forbidden page
|
||||||
|
- [ ] Loading spinners/skeletons
|
||||||
|
- [ ] Empty state illustrations
|
||||||
|
|
||||||
**Testing & Documentation**
|
**Testing & Documentation**
|
||||||
- [ ] Integration tests for REST endpoints
|
- [ ] Integration tests for REST endpoints
|
||||||
- [ ] Integration tests for ActivityPub federation
|
- [ ] Integration tests for ActivityPub federation
|
||||||
- [ ] Integration tests for WebFinger
|
- [ ] Integration tests for WebFinger
|
||||||
|
- [ ] Frontend E2E tests (optional: Playwright/Cypress)
|
||||||
- [ ] README with setup instructions
|
- [ ] README with setup instructions
|
||||||
- [ ] API documentation (Swagger/OpenAPI)
|
- [ ] API documentation (Swagger/OpenAPI)
|
||||||
- [ ] Database setup guide
|
- [ ] Database setup guide
|
||||||
- [ ] Deployment instructions
|
- [ ] Deployment instructions
|
||||||
|
- [ ] Frontend development guide
|
||||||
|
|
||||||
### Phase 2: Social Features
|
### Phase 2: Social Features
|
||||||
- [ ] Likes and comments
|
- [ ] Likes and comments
|
||||||
|
|
|
||||||
350
DOCKER.md
Normal file
350
DOCKER.md
Normal file
|
|
@ -0,0 +1,350 @@
|
||||||
|
# Docker Deployment Guide
|
||||||
|
|
||||||
|
This guide explains how to deploy FitPub using Docker and Docker Compose.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
- Docker Engine 20.10 or later
|
||||||
|
- Docker Compose 2.0 or later
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Clone the Repository
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git clone <repository-url>
|
||||||
|
cd feditrack
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Create Environment File
|
||||||
|
|
||||||
|
Copy the example environment file and customize it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp .env.example .env
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Configure Environment Variables
|
||||||
|
|
||||||
|
Edit `.env` and update the following critical values:
|
||||||
|
|
||||||
|
**Security (REQUIRED):**
|
||||||
|
```bash
|
||||||
|
# Generate a secure JWT secret
|
||||||
|
JWT_SECRET=$(openssl rand -base64 64)
|
||||||
|
|
||||||
|
# Use a strong database password
|
||||||
|
POSTGRES_PASSWORD=$(openssl rand -base64 32)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Domain Configuration (REQUIRED):**
|
||||||
|
```bash
|
||||||
|
APP_DOMAIN=your-domain.com
|
||||||
|
APP_BASE_URL=https://your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Start the Application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Start all services
|
||||||
|
docker-compose up -d
|
||||||
|
|
||||||
|
# View logs
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Check service status
|
||||||
|
docker-compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. Verify Deployment
|
||||||
|
|
||||||
|
The application should be available at:
|
||||||
|
- Application: http://localhost:8080
|
||||||
|
- Health Check: http://localhost:8080/actuator/health
|
||||||
|
|
||||||
|
## Environment Variables
|
||||||
|
|
||||||
|
See `.env.example` for all available configuration options:
|
||||||
|
|
||||||
|
| Variable | Description | Default |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `POSTGRES_DB` | Database name | fitpub |
|
||||||
|
| `POSTGRES_USER` | Database user | fitpub |
|
||||||
|
| `POSTGRES_PASSWORD` | Database password | **MUST CHANGE** |
|
||||||
|
| `POSTGRES_PORT` | Database port | 5432 |
|
||||||
|
| `APP_PORT` | Application port | 8080 |
|
||||||
|
| `APP_DOMAIN` | Your domain name | example.com |
|
||||||
|
| `APP_BASE_URL` | Full application URL | https://example.com |
|
||||||
|
| `JWT_SECRET` | JWT signing secret | **MUST CHANGE** |
|
||||||
|
| `JWT_EXPIRATION_MS` | JWT expiration time | 86400000 (24h) |
|
||||||
|
|
||||||
|
## Docker Compose Services
|
||||||
|
|
||||||
|
### postgres
|
||||||
|
- **Image:** postgis/postgis:16-3.4
|
||||||
|
- **Port:** 5432 (configurable via POSTGRES_PORT)
|
||||||
|
- **Volume:** `postgres_data` - Persistent database storage
|
||||||
|
- **Health Check:** PostgreSQL readiness check
|
||||||
|
|
||||||
|
### app
|
||||||
|
- **Build:** From Dockerfile
|
||||||
|
- **Port:** 8080 (configurable via APP_PORT)
|
||||||
|
- **Volumes:**
|
||||||
|
- `app_uploads` - User uploaded files
|
||||||
|
- `app_logs` - Application logs
|
||||||
|
- **Health Check:** Spring Boot Actuator health endpoint
|
||||||
|
- **Depends On:** postgres (waits for healthy state)
|
||||||
|
|
||||||
|
## Volumes
|
||||||
|
|
||||||
|
Three named volumes are created for data persistence:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# List volumes
|
||||||
|
docker volume ls | grep fitpub
|
||||||
|
|
||||||
|
# Inspect volume
|
||||||
|
docker volume inspect feditrack_postgres_data
|
||||||
|
|
||||||
|
# Backup database volume
|
||||||
|
docker run --rm -v feditrack_postgres_data:/data -v $(pwd):/backup \
|
||||||
|
alpine tar czf /backup/postgres-backup-$(date +%Y%m%d).tar.gz -C /data .
|
||||||
|
|
||||||
|
# Restore database volume
|
||||||
|
docker run --rm -v feditrack_postgres_data:/data -v $(pwd):/backup \
|
||||||
|
alpine tar xzf /backup/postgres-backup-YYYYMMDD.tar.gz -C /data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Common Operations
|
||||||
|
|
||||||
|
### View Logs
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# All services
|
||||||
|
docker-compose logs -f
|
||||||
|
|
||||||
|
# Specific service
|
||||||
|
docker-compose logs -f app
|
||||||
|
docker-compose logs -f postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
### Restart Services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Restart all services
|
||||||
|
docker-compose restart
|
||||||
|
|
||||||
|
# Restart specific service
|
||||||
|
docker-compose restart app
|
||||||
|
```
|
||||||
|
|
||||||
|
### Stop Services
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Stop services (keeps containers)
|
||||||
|
docker-compose stop
|
||||||
|
|
||||||
|
# Stop and remove containers (keeps volumes)
|
||||||
|
docker-compose down
|
||||||
|
|
||||||
|
# Stop and remove everything including volumes (DANGER: data loss)
|
||||||
|
docker-compose down -v
|
||||||
|
```
|
||||||
|
|
||||||
|
### Execute Commands in Container
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Access app container shell
|
||||||
|
docker-compose exec app bash
|
||||||
|
|
||||||
|
# Access PostgreSQL CLI
|
||||||
|
docker-compose exec postgres psql -U fitpub -d fitpub
|
||||||
|
|
||||||
|
# Run SQL query
|
||||||
|
docker-compose exec postgres psql -U fitpub -d fitpub -c "SELECT version();"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Operations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Create database backup
|
||||||
|
docker-compose exec postgres pg_dump -U fitpub fitpub > backup.sql
|
||||||
|
|
||||||
|
# Restore database backup
|
||||||
|
docker-compose exec -T postgres psql -U fitpub fitpub < backup.sql
|
||||||
|
|
||||||
|
# Check Flyway migration status
|
||||||
|
docker-compose exec postgres psql -U fitpub -d fitpub -c \
|
||||||
|
"SELECT * FROM flyway_schema_history ORDER BY installed_rank;"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rebuild Application
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Rebuild and restart app
|
||||||
|
docker-compose up -d --build app
|
||||||
|
|
||||||
|
# Force rebuild without cache
|
||||||
|
docker-compose build --no-cache app
|
||||||
|
docker-compose up -d app
|
||||||
|
```
|
||||||
|
|
||||||
|
## Production Deployment
|
||||||
|
|
||||||
|
### Security Checklist
|
||||||
|
|
||||||
|
- [ ] Change `POSTGRES_PASSWORD` to a strong random password
|
||||||
|
- [ ] Generate secure `JWT_SECRET` using `openssl rand -base64 64`
|
||||||
|
- [ ] Set correct `APP_DOMAIN` and `APP_BASE_URL`
|
||||||
|
- [ ] Configure HTTPS/TLS (use reverse proxy like nginx or Traefik)
|
||||||
|
- [ ] Disable `JPA_SHOW_SQL` and `JPA_FORMAT_SQL`
|
||||||
|
- [ ] Set appropriate log levels (INFO or WARN for production)
|
||||||
|
- [ ] Configure firewall rules (only expose necessary ports)
|
||||||
|
- [ ] Set up regular database backups
|
||||||
|
- [ ] Configure volume backup strategy
|
||||||
|
- [ ] Review and restrict network access
|
||||||
|
|
||||||
|
### Reverse Proxy Example (nginx)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name your-domain.com;
|
||||||
|
return 301 https://$server_name$request_uri;
|
||||||
|
}
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl http2;
|
||||||
|
server_name your-domain.com;
|
||||||
|
|
||||||
|
ssl_certificate /path/to/cert.pem;
|
||||||
|
ssl_certificate_key /path/to/key.pem;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
proxy_pass http://localhost:8080;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Monitoring
|
||||||
|
|
||||||
|
### Health Checks
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Application health
|
||||||
|
curl http://localhost:8080/actuator/health
|
||||||
|
|
||||||
|
# Database health
|
||||||
|
docker-compose exec postgres pg_isready -U fitpub
|
||||||
|
```
|
||||||
|
|
||||||
|
### Resource Usage
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Container stats
|
||||||
|
docker stats
|
||||||
|
|
||||||
|
# Specific container stats
|
||||||
|
docker stats fitpub-app fitpub-postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
## Troubleshooting
|
||||||
|
|
||||||
|
### Application Won't Start
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check logs
|
||||||
|
docker-compose logs app
|
||||||
|
|
||||||
|
# Check if database is ready
|
||||||
|
docker-compose ps postgres
|
||||||
|
docker-compose exec postgres pg_isready -U fitpub
|
||||||
|
|
||||||
|
# Verify environment variables
|
||||||
|
docker-compose config
|
||||||
|
```
|
||||||
|
|
||||||
|
### Database Connection Issues
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check PostgreSQL logs
|
||||||
|
docker-compose logs postgres
|
||||||
|
|
||||||
|
# Test database connection
|
||||||
|
docker-compose exec postgres psql -U fitpub -d fitpub -c "SELECT 1;"
|
||||||
|
|
||||||
|
# Check network connectivity
|
||||||
|
docker-compose exec app ping postgres
|
||||||
|
```
|
||||||
|
|
||||||
|
### Migration Failures
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check Flyway schema history
|
||||||
|
docker-compose exec postgres psql -U fitpub -d fitpub -c \
|
||||||
|
"SELECT * FROM flyway_schema_history;"
|
||||||
|
|
||||||
|
# Reset database (DANGER: data loss)
|
||||||
|
docker-compose down -v
|
||||||
|
docker-compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
### Out of Disk Space
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Check Docker disk usage
|
||||||
|
docker system df
|
||||||
|
|
||||||
|
# Clean up unused resources
|
||||||
|
docker system prune -a --volumes
|
||||||
|
|
||||||
|
# Remove specific volume
|
||||||
|
docker volume rm feditrack_postgres_data
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development Mode
|
||||||
|
|
||||||
|
For local development with live reload:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Use development profile
|
||||||
|
echo "SPRING_PROFILES_ACTIVE=dev" >> .env
|
||||||
|
|
||||||
|
# Enable SQL logging
|
||||||
|
echo "JPA_SHOW_SQL=true" >> .env
|
||||||
|
echo "JPA_FORMAT_SQL=true" >> .env
|
||||||
|
|
||||||
|
# Mount source code for live reload (modify docker-compose.yml)
|
||||||
|
# Add under app.volumes:
|
||||||
|
# - ./src:/app/src
|
||||||
|
```
|
||||||
|
|
||||||
|
## Scaling
|
||||||
|
|
||||||
|
To run multiple app instances behind a load balancer:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Scale app service
|
||||||
|
docker-compose up -d --scale app=3
|
||||||
|
|
||||||
|
# Note: You'll need to configure a load balancer and remove
|
||||||
|
# the container_name directive from docker-compose.yml
|
||||||
|
```
|
||||||
|
|
||||||
|
## Updating
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Pull latest code
|
||||||
|
git pull
|
||||||
|
|
||||||
|
# Rebuild and restart
|
||||||
|
docker-compose down
|
||||||
|
docker-compose up -d --build
|
||||||
|
|
||||||
|
# Check migration status
|
||||||
|
docker-compose logs app | grep -i flyway
|
||||||
|
```
|
||||||
59
Dockerfile
Normal file
59
Dockerfile
Normal file
|
|
@ -0,0 +1,59 @@
|
||||||
|
# Multi-stage build for FitPub application
|
||||||
|
|
||||||
|
# Stage 1: Build the application
|
||||||
|
FROM maven:3.9-eclipse-temurin-17 AS builder
|
||||||
|
|
||||||
|
WORKDIR /build
|
||||||
|
|
||||||
|
# Copy POM file first for better layer caching
|
||||||
|
COPY pom.xml .
|
||||||
|
|
||||||
|
# Download dependencies (cached if pom.xml hasn't changed)
|
||||||
|
RUN mvn dependency:go-offline -B
|
||||||
|
|
||||||
|
# Copy source code
|
||||||
|
COPY src ./src
|
||||||
|
|
||||||
|
# Build the application
|
||||||
|
RUN mvn clean package -DskipTests -B
|
||||||
|
|
||||||
|
# Stage 2: Create the runtime image
|
||||||
|
FROM eclipse-temurin:17-jre-jammy
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Install curl for healthcheck
|
||||||
|
RUN apt-get update && \
|
||||||
|
apt-get install -y --no-install-recommends curl && \
|
||||||
|
rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Create non-root user for security
|
||||||
|
RUN groupadd -r fitpub && useradd -r -g fitpub fitpub
|
||||||
|
|
||||||
|
# Create directories for uploads and logs
|
||||||
|
RUN mkdir -p /app/uploads /app/logs && \
|
||||||
|
chown -R fitpub:fitpub /app
|
||||||
|
|
||||||
|
# Copy the built artifact from builder stage
|
||||||
|
COPY --from=builder /build/target/*.jar /app/fitpub.jar
|
||||||
|
|
||||||
|
# Change ownership
|
||||||
|
RUN chown fitpub:fitpub /app/fitpub.jar
|
||||||
|
|
||||||
|
# Switch to non-root user
|
||||||
|
USER fitpub
|
||||||
|
|
||||||
|
# Expose application port
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
# Health check
|
||||||
|
HEALTHCHECK --interval=30s --timeout=10s --start-period=60s --retries=3 \
|
||||||
|
CMD curl -f http://localhost:8080/actuator/health || exit 1
|
||||||
|
|
||||||
|
# Run the application
|
||||||
|
ENTRYPOINT ["java", \
|
||||||
|
"-XX:+UseContainerSupport", \
|
||||||
|
"-XX:MaxRAMPercentage=75.0", \
|
||||||
|
"-Djava.security.egd=file:/dev/./urandom", \
|
||||||
|
"-jar", \
|
||||||
|
"/app/fitpub.jar"]
|
||||||
102
docker-compose.yml
Normal file
102
docker-compose.yml
Normal file
|
|
@ -0,0 +1,102 @@
|
||||||
|
version: '3.8'
|
||||||
|
|
||||||
|
services:
|
||||||
|
postgres:
|
||||||
|
image: postgis/postgis:16-3.4
|
||||||
|
container_name: fitpub-postgres
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB}
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
PGDATA: /var/lib/postgresql/data/pgdata
|
||||||
|
ports:
|
||||||
|
- "${POSTGRES_PORT:-5432}:5432"
|
||||||
|
volumes:
|
||||||
|
- postgres_data:/var/lib/postgresql/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
networks:
|
||||||
|
- fitpub-network
|
||||||
|
|
||||||
|
app:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
container_name: fitpub-app
|
||||||
|
restart: unless-stopped
|
||||||
|
depends_on:
|
||||||
|
postgres:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
# Spring Profile
|
||||||
|
SPRING_PROFILES_ACTIVE: ${SPRING_PROFILES_ACTIVE:-prod}
|
||||||
|
|
||||||
|
# Database Configuration
|
||||||
|
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/${POSTGRES_DB}
|
||||||
|
SPRING_DATASOURCE_USERNAME: ${POSTGRES_USER}
|
||||||
|
SPRING_DATASOURCE_PASSWORD: ${POSTGRES_PASSWORD}
|
||||||
|
|
||||||
|
# Hibernate Configuration
|
||||||
|
SPRING_JPA_HIBERNATE_DDL_AUTO: validate
|
||||||
|
SPRING_JPA_SHOW_SQL: ${JPA_SHOW_SQL:-false}
|
||||||
|
SPRING_JPA_PROPERTIES_HIBERNATE_FORMAT_SQL: ${JPA_FORMAT_SQL:-false}
|
||||||
|
SPRING_JPA_PROPERTIES_HIBERNATE_DIALECT: org.hibernate.spatial.dialect.postgis.PostgisDialect
|
||||||
|
|
||||||
|
# Flyway Configuration
|
||||||
|
SPRING_FLYWAY_ENABLED: true
|
||||||
|
SPRING_FLYWAY_BASELINE_ON_MIGRATE: true
|
||||||
|
SPRING_FLYWAY_VALIDATE_ON_MIGRATE: true
|
||||||
|
|
||||||
|
# Server Configuration
|
||||||
|
SERVER_PORT: ${APP_PORT:-8080}
|
||||||
|
|
||||||
|
# Application Configuration
|
||||||
|
APP_DOMAIN: ${APP_DOMAIN}
|
||||||
|
APP_BASE_URL: ${APP_BASE_URL}
|
||||||
|
|
||||||
|
# Security Configuration
|
||||||
|
JWT_SECRET: ${JWT_SECRET}
|
||||||
|
JWT_EXPIRATION_MS: ${JWT_EXPIRATION_MS:-86400000}
|
||||||
|
|
||||||
|
# ActivityPub Configuration
|
||||||
|
ACTIVITYPUB_ENABLED: ${ACTIVITYPUB_ENABLED:-true}
|
||||||
|
|
||||||
|
# File Storage
|
||||||
|
FILE_UPLOAD_MAX_SIZE: ${FILE_UPLOAD_MAX_SIZE:-50MB}
|
||||||
|
FILE_UPLOAD_DIR: ${FILE_UPLOAD_DIR:-/app/uploads}
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
LOGGING_LEVEL_ROOT: ${LOG_LEVEL_ROOT:-INFO}
|
||||||
|
LOGGING_LEVEL_ORG_OPERATON: ${LOG_LEVEL_APP:-INFO}
|
||||||
|
LOGGING_LEVEL_ORG_SPRINGFRAMEWORK: ${LOG_LEVEL_SPRING:-INFO}
|
||||||
|
LOGGING_LEVEL_ORG_HIBERNATE: ${LOG_LEVEL_HIBERNATE:-WARN}
|
||||||
|
LOGGING_LEVEL_ORG_FLYWAYDB: ${LOG_LEVEL_FLYWAY:-INFO}
|
||||||
|
ports:
|
||||||
|
- "${APP_PORT:-8080}:8080"
|
||||||
|
volumes:
|
||||||
|
- app_uploads:/app/uploads
|
||||||
|
- app_logs:/app/logs
|
||||||
|
networks:
|
||||||
|
- fitpub-network
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:8080/actuator/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 10s
|
||||||
|
retries: 3
|
||||||
|
start_period: 60s
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
postgres_data:
|
||||||
|
driver: local
|
||||||
|
app_uploads:
|
||||||
|
driver: local
|
||||||
|
app_logs:
|
||||||
|
driver: local
|
||||||
|
|
||||||
|
networks:
|
||||||
|
fitpub-network:
|
||||||
|
driver: bridge
|
||||||
22
pom.xml
22
pom.xml
|
|
@ -43,6 +43,24 @@
|
||||||
<groupId>org.springframework.boot</groupId>
|
<groupId>org.springframework.boot</groupId>
|
||||||
<artifactId>spring-boot-starter-validation</artifactId>
|
<artifactId>spring-boot-starter-validation</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-actuator</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Thymeleaf for server-side templates -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-starter-thymeleaf</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>nz.net.ultraq.thymeleaf</groupId>
|
||||||
|
<artifactId>thymeleaf-layout-dialect</artifactId>
|
||||||
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.thymeleaf.extras</groupId>
|
||||||
|
<artifactId>thymeleaf-extras-springsecurity6</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Database -->
|
<!-- Database -->
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|
@ -54,6 +72,10 @@
|
||||||
<groupId>org.hibernate.orm</groupId>
|
<groupId>org.hibernate.orm</groupId>
|
||||||
<artifactId>hibernate-spatial</artifactId>
|
<artifactId>hibernate-spatial</artifactId>
|
||||||
</dependency>
|
</dependency>
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-core</artifactId>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
<!-- Testcontainers for Dev Services -->
|
<!-- Testcontainers for Dev Services -->
|
||||||
<dependency>
|
<dependency>
|
||||||
|
|
|
||||||
|
|
@ -49,18 +49,36 @@ public class SecurityConfig {
|
||||||
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
|
||||||
)
|
)
|
||||||
.authorizeHttpRequests(auth -> auth
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
// Public endpoints - Static resources
|
||||||
|
.requestMatchers("/css/**", "/js/**", "/img/**", "/favicon.ico").permitAll()
|
||||||
|
|
||||||
|
// Public endpoints - Error pages
|
||||||
|
.requestMatchers("/error").permitAll()
|
||||||
|
|
||||||
|
// Public endpoints - Web UI pages
|
||||||
|
.requestMatchers("/", "/login", "/register", "/timeline", "/activities", "/activities/**").permitAll()
|
||||||
|
|
||||||
// Public endpoints - ActivityPub federation
|
// Public endpoints - ActivityPub federation
|
||||||
.requestMatchers("/.well-known/**").permitAll()
|
.requestMatchers("/.well-known/**").permitAll()
|
||||||
.requestMatchers(HttpMethod.GET, "/users/**").permitAll()
|
.requestMatchers(HttpMethod.GET, "/users/**").permitAll()
|
||||||
.requestMatchers(HttpMethod.POST, "/users/*/inbox").permitAll()
|
.requestMatchers(HttpMethod.POST, "/users/*/inbox").permitAll()
|
||||||
|
|
||||||
// Public endpoints - Authentication
|
// Public endpoints - Authentication API
|
||||||
.requestMatchers("/api/auth/**").permitAll()
|
.requestMatchers("/api/auth/**").permitAll()
|
||||||
.requestMatchers("/api/users/register").permitAll()
|
.requestMatchers("/api/users/register").permitAll()
|
||||||
|
|
||||||
// Protected endpoints - Activities
|
// Public endpoints - Timeline API (read-only)
|
||||||
|
.requestMatchers(HttpMethod.GET, "/api/timeline/public").permitAll()
|
||||||
|
|
||||||
|
// Protected endpoints - Activities API
|
||||||
.requestMatchers("/api/activities/**").authenticated()
|
.requestMatchers("/api/activities/**").authenticated()
|
||||||
|
|
||||||
|
// Protected endpoints - Timeline API (user-specific)
|
||||||
|
.requestMatchers("/api/timeline/**").authenticated()
|
||||||
|
|
||||||
|
// Protected web pages
|
||||||
|
.requestMatchers("/profile", "/settings").authenticated()
|
||||||
|
|
||||||
// All other requests require authentication
|
// All other requests require authentication
|
||||||
.anyRequest().authenticated()
|
.anyRequest().authenticated()
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ package org.operaton.fitpub.config;
|
||||||
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
import org.springframework.boot.testcontainers.service.connection.ServiceConnection;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.context.annotation.Profile;
|
||||||
import org.testcontainers.containers.PostgreSQLContainer;
|
import org.testcontainers.containers.PostgreSQLContainer;
|
||||||
import org.testcontainers.utility.DockerImageName;
|
import org.testcontainers.utility.DockerImageName;
|
||||||
|
|
||||||
|
|
@ -11,8 +12,11 @@ import org.testcontainers.utility.DockerImageName;
|
||||||
* Automatically starts a PostgreSQL container with PostGIS extension when running in dev mode.
|
* Automatically starts a PostgreSQL container with PostGIS extension when running in dev mode.
|
||||||
*
|
*
|
||||||
* This ensures development environment matches production (PostgreSQL + PostGIS).
|
* This ensures development environment matches production (PostgreSQL + PostGIS).
|
||||||
|
*
|
||||||
|
* Only active when NOT running in production profile.
|
||||||
*/
|
*/
|
||||||
@Configuration(proxyBeanMethods = false)
|
@Configuration(proxyBeanMethods = false)
|
||||||
|
@Profile("!prod")
|
||||||
public class TestcontainersConfiguration {
|
public class TestcontainersConfiguration {
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package org.operaton.fitpub.config;
|
||||||
|
|
||||||
|
import nz.net.ultraq.thymeleaf.layoutdialect.LayoutDialect;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Thymeleaf configuration for Layout Dialect support
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class ThymeleafConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Configure Thymeleaf Layout Dialect for template inheritance
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public LayoutDialect layoutDialect() {
|
||||||
|
return new LayoutDialect();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,48 @@
|
||||||
|
package org.operaton.fitpub.controller;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PathVariable;
|
||||||
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for serving activity-related HTML pages
|
||||||
|
*/
|
||||||
|
@Controller
|
||||||
|
@RequestMapping("/activities")
|
||||||
|
public class ActivitiesViewController {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show activities list page
|
||||||
|
*/
|
||||||
|
@GetMapping
|
||||||
|
public String listActivities() {
|
||||||
|
return "activities/list";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show activity upload page
|
||||||
|
*/
|
||||||
|
@GetMapping("/upload")
|
||||||
|
public String uploadActivity() {
|
||||||
|
return "activities/upload";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show activity detail page
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}")
|
||||||
|
public String viewActivity(@PathVariable String id) {
|
||||||
|
// The activity data will be loaded via JavaScript API calls
|
||||||
|
return "activities/detail";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Show activity edit page
|
||||||
|
*/
|
||||||
|
@GetMapping("/{id}/edit")
|
||||||
|
public String editActivity(@PathVariable String id) {
|
||||||
|
// The activity data will be loaded via JavaScript API calls
|
||||||
|
return "activities/edit";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -27,6 +27,7 @@ import java.util.Optional;
|
||||||
public class ActivityPubController {
|
public class ActivityPubController {
|
||||||
|
|
||||||
private final UserRepository userRepository;
|
private final UserRepository userRepository;
|
||||||
|
private final org.operaton.fitpub.service.InboxProcessor inboxProcessor;
|
||||||
|
|
||||||
@Value("${fitpub.base-url}")
|
@Value("${fitpub.base-url}")
|
||||||
private String baseUrl;
|
private String baseUrl;
|
||||||
|
|
@ -79,10 +80,16 @@ public class ActivityPubController {
|
||||||
) {
|
) {
|
||||||
log.info("Received ActivityPub activity for user {}: {}", username, activity.get("type"));
|
log.info("Received ActivityPub activity for user {}: {}", username, activity.get("type"));
|
||||||
|
|
||||||
// TODO: Validate HTTP signature
|
// TODO: Validate HTTP signature (signature validation can be added later)
|
||||||
// TODO: Process activity based on type (Follow, Like, Create, etc.)
|
|
||||||
|
|
||||||
// For MVP, just accept all activities
|
// Process activity asynchronously to avoid blocking the sender
|
||||||
|
try {
|
||||||
|
inboxProcessor.processActivity(username, activity);
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error processing inbox activity", e);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always return 202 Accepted per ActivityPub spec
|
||||||
return ResponseEntity.status(HttpStatus.ACCEPTED).build();
|
return ResponseEntity.status(HttpStatus.ACCEPTED).build();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,29 @@
|
||||||
|
package org.operaton.fitpub.controller;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for authentication-related web pages
|
||||||
|
*/
|
||||||
|
@Controller
|
||||||
|
public class AuthViewController {
|
||||||
|
|
||||||
|
@GetMapping("/login")
|
||||||
|
public String login() {
|
||||||
|
return "auth/login";
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping("/register")
|
||||||
|
public String register() {
|
||||||
|
return "auth/register";
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping("/logout")
|
||||||
|
public String logout() {
|
||||||
|
// Logout is handled client-side (removing JWT token)
|
||||||
|
// Redirect to home page
|
||||||
|
return "redirect:/";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,16 @@
|
||||||
|
package org.operaton.fitpub.controller;
|
||||||
|
|
||||||
|
import org.springframework.stereotype.Controller;
|
||||||
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Controller for home page and general public pages
|
||||||
|
*/
|
||||||
|
@Controller
|
||||||
|
public class HomeController {
|
||||||
|
|
||||||
|
@GetMapping("/")
|
||||||
|
public String home() {
|
||||||
|
return "index";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,126 @@
|
||||||
|
package org.operaton.fitpub.controller;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.operaton.fitpub.model.dto.TimelineActivityDTO;
|
||||||
|
import org.operaton.fitpub.service.TimelineService;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageRequest;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* REST controller for timeline endpoints.
|
||||||
|
* Provides access to federated, public, and user timelines.
|
||||||
|
*/
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/timeline")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class TimelineController {
|
||||||
|
|
||||||
|
private final TimelineService timelineService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the federated timeline for the authenticated user.
|
||||||
|
* Shows activities from users they follow.
|
||||||
|
*
|
||||||
|
* GET /api/timeline/federated?page=0&size=20
|
||||||
|
*
|
||||||
|
* @param authentication the authenticated user
|
||||||
|
* @param page page number (default: 0)
|
||||||
|
* @param size page size (default: 20)
|
||||||
|
* @return page of timeline activities
|
||||||
|
*/
|
||||||
|
@GetMapping("/federated")
|
||||||
|
public ResponseEntity<Page<TimelineActivityDTO>> getFederatedTimeline(
|
||||||
|
Authentication authentication,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size
|
||||||
|
) {
|
||||||
|
UUID userId = UUID.fromString(authentication.getName());
|
||||||
|
log.debug("Federated timeline request from user: {}", userId);
|
||||||
|
|
||||||
|
Pageable pageable = PageRequest.of(page, size);
|
||||||
|
Page<TimelineActivityDTO> timeline = timelineService.getFederatedTimeline(userId, pageable);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(timeline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the public timeline.
|
||||||
|
* Shows all public activities from all users.
|
||||||
|
*
|
||||||
|
* GET /api/timeline/public?page=0&size=20
|
||||||
|
*
|
||||||
|
* @param page page number (default: 0)
|
||||||
|
* @param size page size (default: 20)
|
||||||
|
* @return page of timeline activities
|
||||||
|
*/
|
||||||
|
@GetMapping("/public")
|
||||||
|
public ResponseEntity<Page<TimelineActivityDTO>> getPublicTimeline(
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size
|
||||||
|
) {
|
||||||
|
log.debug("Public timeline request");
|
||||||
|
|
||||||
|
Pageable pageable = PageRequest.of(page, size);
|
||||||
|
Page<TimelineActivityDTO> timeline = timelineService.getPublicTimeline(pageable);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(timeline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the user's own timeline.
|
||||||
|
* Shows only activities by the authenticated user.
|
||||||
|
*
|
||||||
|
* GET /api/timeline/user?page=0&size=20
|
||||||
|
*
|
||||||
|
* @param authentication the authenticated user
|
||||||
|
* @param page page number (default: 0)
|
||||||
|
* @param size page size (default: 20)
|
||||||
|
* @return page of timeline activities
|
||||||
|
*/
|
||||||
|
@GetMapping("/user")
|
||||||
|
public ResponseEntity<Page<TimelineActivityDTO>> getUserTimeline(
|
||||||
|
Authentication authentication,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size
|
||||||
|
) {
|
||||||
|
UUID userId = UUID.fromString(authentication.getName());
|
||||||
|
log.debug("User timeline request from user: {}", userId);
|
||||||
|
|
||||||
|
Pageable pageable = PageRequest.of(page, size);
|
||||||
|
Page<TimelineActivityDTO> timeline = timelineService.getUserTimeline(userId, pageable);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(timeline);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get another user's public timeline by username.
|
||||||
|
* Shows public activities by a specific user.
|
||||||
|
*
|
||||||
|
* GET /api/timeline/user/{username}?page=0&size=20
|
||||||
|
*
|
||||||
|
* @param username the username
|
||||||
|
* @param page page number (default: 0)
|
||||||
|
* @param size page size (default: 20)
|
||||||
|
* @return page of timeline activities
|
||||||
|
*/
|
||||||
|
@GetMapping("/user/{username}")
|
||||||
|
public ResponseEntity<Page<TimelineActivityDTO>> getUserTimelineByUsername(
|
||||||
|
@PathVariable String username,
|
||||||
|
@RequestParam(defaultValue = "0") int page,
|
||||||
|
@RequestParam(defaultValue = "20") int size
|
||||||
|
) {
|
||||||
|
log.debug("User timeline request for username: {}", username);
|
||||||
|
|
||||||
|
// TODO: Implement getUserTimelineByUsername in TimelineService
|
||||||
|
// For now, return not implemented
|
||||||
|
return ResponseEntity.status(501).build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,6 +24,5 @@ public class ActivityUploadRequest {
|
||||||
@Size(max = 5000, message = "Description must not exceed 5000 characters")
|
@Size(max = 5000, message = "Description must not exceed 5000 characters")
|
||||||
private String description;
|
private String description;
|
||||||
|
|
||||||
@NotNull(message = "Visibility is required")
|
|
||||||
private Activity.Visibility visibility;
|
private Activity.Visibility visibility;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,97 @@
|
||||||
|
package org.operaton.fitpub.model.dto;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.operaton.fitpub.model.entity.Activity;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* DTO for timeline activity items.
|
||||||
|
* Represents an activity in the federated timeline.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class TimelineActivityDTO {
|
||||||
|
|
||||||
|
private UUID id;
|
||||||
|
private String activityType;
|
||||||
|
private String title;
|
||||||
|
private String description;
|
||||||
|
private LocalDateTime startedAt;
|
||||||
|
private LocalDateTime endedAt;
|
||||||
|
private Double totalDistance;
|
||||||
|
private Long totalDurationSeconds;
|
||||||
|
private Double elevationGain;
|
||||||
|
private Double elevationLoss;
|
||||||
|
private String visibility;
|
||||||
|
private LocalDateTime createdAt;
|
||||||
|
|
||||||
|
// User information
|
||||||
|
private String username;
|
||||||
|
private String displayName;
|
||||||
|
private String avatarUrl;
|
||||||
|
private boolean isLocal;
|
||||||
|
|
||||||
|
// Metrics summary
|
||||||
|
private ActivityMetricsSummary metrics;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert Activity entity to timeline DTO.
|
||||||
|
*/
|
||||||
|
public static TimelineActivityDTO fromActivity(Activity activity, String username, String displayName, String avatarUrl) {
|
||||||
|
return TimelineActivityDTO.builder()
|
||||||
|
.id(activity.getId())
|
||||||
|
.activityType(activity.getActivityType().name())
|
||||||
|
.title(activity.getTitle())
|
||||||
|
.description(activity.getDescription())
|
||||||
|
.startedAt(activity.getStartedAt())
|
||||||
|
.endedAt(activity.getEndedAt())
|
||||||
|
.totalDistance(activity.getTotalDistance() != null ? activity.getTotalDistance().doubleValue() : null)
|
||||||
|
.totalDurationSeconds(activity.getTotalDurationSeconds())
|
||||||
|
.elevationGain(activity.getElevationGain() != null ? activity.getElevationGain().doubleValue() : null)
|
||||||
|
.elevationLoss(activity.getElevationLoss() != null ? activity.getElevationLoss().doubleValue() : null)
|
||||||
|
.visibility(activity.getVisibility().name())
|
||||||
|
.createdAt(activity.getCreatedAt())
|
||||||
|
.username(username)
|
||||||
|
.displayName(displayName)
|
||||||
|
.avatarUrl(avatarUrl)
|
||||||
|
.isLocal(true)
|
||||||
|
.metrics(activity.getMetrics() != null ? ActivityMetricsSummary.fromMetrics(activity.getMetrics()) : null)
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Summary of activity metrics for timeline display.
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public static class ActivityMetricsSummary {
|
||||||
|
private Integer averageHeartRate;
|
||||||
|
private Integer maxHeartRate;
|
||||||
|
private Double averageSpeed;
|
||||||
|
private Double maxSpeed;
|
||||||
|
private Long averagePaceSeconds;
|
||||||
|
private Integer averagePower;
|
||||||
|
private Integer calories;
|
||||||
|
|
||||||
|
public static ActivityMetricsSummary fromMetrics(org.operaton.fitpub.model.entity.ActivityMetrics metrics) {
|
||||||
|
return ActivityMetricsSummary.builder()
|
||||||
|
.averageHeartRate(metrics.getAverageHeartRate())
|
||||||
|
.maxHeartRate(metrics.getMaxHeartRate())
|
||||||
|
.averageSpeed(metrics.getAverageSpeed() != null ? metrics.getAverageSpeed().doubleValue() : null)
|
||||||
|
.maxSpeed(metrics.getMaxSpeed() != null ? metrics.getMaxSpeed().doubleValue() : null)
|
||||||
|
.averagePaceSeconds(metrics.getAveragePaceSeconds())
|
||||||
|
.averagePower(metrics.getAveragePower())
|
||||||
|
.calories(metrics.getCalories())
|
||||||
|
.build();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
75
src/main/java/org/operaton/fitpub/model/entity/Follow.java
Normal file
75
src/main/java/org/operaton/fitpub/model/entity/Follow.java
Normal file
|
|
@ -0,0 +1,75 @@
|
||||||
|
package org.operaton.fitpub.model.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a follow relationship between users (local or remote).
|
||||||
|
* Supports both local-to-local and local-to-remote follow relationships.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "follows", indexes = {
|
||||||
|
@Index(name = "idx_follower_id", columnList = "follower_id"),
|
||||||
|
@Index(name = "idx_following_actor_uri", columnList = "following_actor_uri")
|
||||||
|
})
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class Follow {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The local user who is following.
|
||||||
|
*/
|
||||||
|
@Column(name = "follower_id", nullable = false)
|
||||||
|
private UUID followerId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ActivityPub actor URI being followed (local or remote).
|
||||||
|
* Example: https://mastodon.social/users/alice
|
||||||
|
*/
|
||||||
|
@Column(name = "following_actor_uri", nullable = false, length = 512)
|
||||||
|
private String followingActorUri;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status of the follow relationship.
|
||||||
|
*/
|
||||||
|
@Enumerated(EnumType.STRING)
|
||||||
|
@Column(nullable = false, length = 20)
|
||||||
|
@Builder.Default
|
||||||
|
private FollowStatus status = FollowStatus.PENDING;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The ActivityPub Follow activity ID.
|
||||||
|
* Used to reference the original Follow activity.
|
||||||
|
*/
|
||||||
|
@Column(name = "activity_id", length = 512)
|
||||||
|
private String activityId;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Status of a follow relationship.
|
||||||
|
*/
|
||||||
|
public enum FollowStatus {
|
||||||
|
/** Follow request sent, awaiting acceptance */
|
||||||
|
PENDING,
|
||||||
|
/** Follow request accepted, relationship active */
|
||||||
|
ACCEPTED,
|
||||||
|
/** Follow request rejected */
|
||||||
|
REJECTED
|
||||||
|
}
|
||||||
|
}
|
||||||
126
src/main/java/org/operaton/fitpub/model/entity/RemoteActor.java
Normal file
126
src/main/java/org/operaton/fitpub/model/entity/RemoteActor.java
Normal file
|
|
@ -0,0 +1,126 @@
|
||||||
|
package org.operaton.fitpub.model.entity;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
import lombok.NoArgsConstructor;
|
||||||
|
import org.hibernate.annotations.CreationTimestamp;
|
||||||
|
import org.hibernate.annotations.UpdateTimestamp;
|
||||||
|
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Represents a remote ActivityPub actor (user from another server).
|
||||||
|
* Cached information about remote actors for federation.
|
||||||
|
*/
|
||||||
|
@Entity
|
||||||
|
@Table(name = "remote_actors", indexes = {
|
||||||
|
@Index(name = "idx_actor_uri", columnList = "actor_uri", unique = true),
|
||||||
|
@Index(name = "idx_domain", columnList = "domain")
|
||||||
|
})
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
@NoArgsConstructor
|
||||||
|
@AllArgsConstructor
|
||||||
|
public class RemoteActor {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.UUID)
|
||||||
|
private UUID id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The full ActivityPub actor URI.
|
||||||
|
* Example: https://mastodon.social/users/alice
|
||||||
|
*/
|
||||||
|
@Column(name = "actor_uri", nullable = false, unique = true, length = 512)
|
||||||
|
private String actorUri;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The username part of the actor.
|
||||||
|
* Example: alice
|
||||||
|
*/
|
||||||
|
@Column(nullable = false, length = 255)
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The domain of the remote server.
|
||||||
|
* Example: mastodon.social
|
||||||
|
*/
|
||||||
|
@Column(nullable = false, length = 255)
|
||||||
|
private String domain;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The actor's inbox URL for sending activities.
|
||||||
|
*/
|
||||||
|
@Column(name = "inbox_url", nullable = false, length = 512)
|
||||||
|
private String inboxUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The actor's outbox URL for fetching activities.
|
||||||
|
*/
|
||||||
|
@Column(name = "outbox_url", length = 512)
|
||||||
|
private String outboxUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The actor's shared inbox URL (if available).
|
||||||
|
* More efficient for server-to-server communication.
|
||||||
|
*/
|
||||||
|
@Column(name = "shared_inbox_url", length = 512)
|
||||||
|
private String sharedInboxUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The actor's public key in PEM format.
|
||||||
|
* Used for verifying HTTP signatures.
|
||||||
|
*/
|
||||||
|
@Column(name = "public_key", columnDefinition = "TEXT", nullable = false)
|
||||||
|
private String publicKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The actor's public key ID.
|
||||||
|
* Example: https://mastodon.social/users/alice#main-key
|
||||||
|
*/
|
||||||
|
@Column(name = "public_key_id", length = 512)
|
||||||
|
private String publicKeyId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The actor's display name.
|
||||||
|
*/
|
||||||
|
@Column(name = "display_name", length = 255)
|
||||||
|
private String displayName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The actor's avatar URL.
|
||||||
|
*/
|
||||||
|
@Column(name = "avatar_url", length = 512)
|
||||||
|
private String avatarUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The actor's bio/summary.
|
||||||
|
*/
|
||||||
|
@Column(columnDefinition = "TEXT")
|
||||||
|
private String summary;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* When the actor information was last fetched/updated.
|
||||||
|
*/
|
||||||
|
@Column(name = "last_fetched_at")
|
||||||
|
private Instant lastFetchedAt;
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
@Column(name = "created_at", nullable = false, updatable = false)
|
||||||
|
private Instant createdAt;
|
||||||
|
|
||||||
|
@UpdateTimestamp
|
||||||
|
@Column(name = "updated_at", nullable = false)
|
||||||
|
private Instant updatedAt;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Extract username and domain from actor URI.
|
||||||
|
* Example: https://mastodon.social/users/alice -> alice@mastodon.social
|
||||||
|
*/
|
||||||
|
public String getHandle() {
|
||||||
|
return username + "@" + domain;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package org.operaton.fitpub.repository;
|
package org.operaton.fitpub.repository;
|
||||||
|
|
||||||
import org.operaton.fitpub.model.entity.Activity;
|
import org.operaton.fitpub.model.entity.Activity;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
import org.springframework.data.jpa.repository.JpaRepository;
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
import org.springframework.data.jpa.repository.Query;
|
import org.springframework.data.jpa.repository.Query;
|
||||||
import org.springframework.data.repository.query.Param;
|
import org.springframework.data.repository.query.Param;
|
||||||
|
|
@ -85,4 +87,41 @@ public interface ActivityRepository extends JpaRepository<Activity, UUID> {
|
||||||
* @param userId the user ID
|
* @param userId the user ID
|
||||||
*/
|
*/
|
||||||
void deleteByUserId(UUID userId);
|
void deleteByUserId(UUID userId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find activities for a user with pagination.
|
||||||
|
*
|
||||||
|
* @param userId the user ID
|
||||||
|
* @param pageable pagination parameters
|
||||||
|
* @return page of activities
|
||||||
|
*/
|
||||||
|
Page<Activity> findByUserIdOrderByStartedAtDesc(UUID userId, Pageable pageable);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find activities by user IDs and visibility with pagination.
|
||||||
|
* Used for federated timeline.
|
||||||
|
*
|
||||||
|
* @param userIds list of user IDs
|
||||||
|
* @param visibilities list of visibility values
|
||||||
|
* @param pageable pagination parameters
|
||||||
|
* @return page of activities
|
||||||
|
*/
|
||||||
|
Page<Activity> findByUserIdInAndVisibilityInOrderByStartedAtDesc(
|
||||||
|
List<UUID> userIds,
|
||||||
|
List<Activity.Visibility> visibilities,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all public activities with pagination.
|
||||||
|
* Used for public timeline.
|
||||||
|
*
|
||||||
|
* @param visibility the visibility level
|
||||||
|
* @param pageable pagination parameters
|
||||||
|
* @return page of activities
|
||||||
|
*/
|
||||||
|
Page<Activity> findByVisibilityOrderByStartedAtDesc(
|
||||||
|
Activity.Visibility visibility,
|
||||||
|
Pageable pageable
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,70 @@
|
||||||
|
package org.operaton.fitpub.repository;
|
||||||
|
|
||||||
|
import org.operaton.fitpub.model.entity.Follow;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.data.jpa.repository.Query;
|
||||||
|
import org.springframework.data.repository.query.Param;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for Follow entity operations.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface FollowRepository extends JpaRepository<Follow, UUID> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a follow relationship by follower and following actor URI.
|
||||||
|
*
|
||||||
|
* @param followerId the follower's user ID
|
||||||
|
* @param followingActorUri the actor URI being followed
|
||||||
|
* @return the follow relationship if it exists
|
||||||
|
*/
|
||||||
|
Optional<Follow> findByFollowerIdAndFollowingActorUri(UUID followerId, String followingActorUri);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all follow relationships for a follower.
|
||||||
|
*
|
||||||
|
* @param followerId the follower's user ID
|
||||||
|
* @return list of follow relationships
|
||||||
|
*/
|
||||||
|
List<Follow> findByFollowerId(UUID followerId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all accepted followers of a user by their actor URI.
|
||||||
|
*
|
||||||
|
* @param actorUri the actor URI being followed
|
||||||
|
* @return list of accepted follow relationships
|
||||||
|
*/
|
||||||
|
@Query("SELECT f FROM Follow f WHERE f.followingActorUri = :actorUri AND f.status = 'ACCEPTED'")
|
||||||
|
List<Follow> findAcceptedFollowersByActorUri(@Param("actorUri") String actorUri);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Count accepted followers for an actor URI.
|
||||||
|
*
|
||||||
|
* @param actorUri the actor URI
|
||||||
|
* @return count of accepted followers
|
||||||
|
*/
|
||||||
|
@Query("SELECT COUNT(f) FROM Follow f WHERE f.followingActorUri = :actorUri AND f.status = 'ACCEPTED'")
|
||||||
|
long countAcceptedFollowersByActorUri(@Param("actorUri") String actorUri);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find all accepted following relationships for a user.
|
||||||
|
*
|
||||||
|
* @param followerId the follower's user ID
|
||||||
|
* @return list of accepted follow relationships
|
||||||
|
*/
|
||||||
|
@Query("SELECT f FROM Follow f WHERE f.followerId = :followerId AND f.status = 'ACCEPTED'")
|
||||||
|
List<Follow> findAcceptedFollowingByUserId(@Param("followerId") UUID followerId);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a follow by its Activity ID.
|
||||||
|
*
|
||||||
|
* @param activityId the ActivityPub Follow activity ID
|
||||||
|
* @return the follow relationship if it exists
|
||||||
|
*/
|
||||||
|
Optional<Follow> findByActivityId(String activityId);
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,31 @@
|
||||||
|
package org.operaton.fitpub.repository;
|
||||||
|
|
||||||
|
import org.operaton.fitpub.model.entity.RemoteActor;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
import org.springframework.stereotype.Repository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Repository for RemoteActor entity operations.
|
||||||
|
*/
|
||||||
|
@Repository
|
||||||
|
public interface RemoteActorRepository extends JpaRepository<RemoteActor, UUID> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Find a remote actor by their ActivityPub URI.
|
||||||
|
*
|
||||||
|
* @param actorUri the actor URI
|
||||||
|
* @return the remote actor if found
|
||||||
|
*/
|
||||||
|
Optional<RemoteActor> findByActorUri(String actorUri);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a remote actor exists by their actor URI.
|
||||||
|
*
|
||||||
|
* @param actorUri the actor URI
|
||||||
|
* @return true if the actor exists
|
||||||
|
*/
|
||||||
|
boolean existsByActorUri(String actorUri);
|
||||||
|
}
|
||||||
|
|
@ -196,4 +196,57 @@ public class HttpSignatureValidator {
|
||||||
|
|
||||||
return Base64.getEncoder().encodeToString(signature);
|
return Base64.getEncoder().encodeToString(signature);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Signs an outbound HTTP request for ActivityPub federation.
|
||||||
|
*
|
||||||
|
* @param method the HTTP method (e.g., "POST")
|
||||||
|
* @param targetUrl the target URL
|
||||||
|
* @param body the request body
|
||||||
|
* @param privateKeyPem the sender's private key
|
||||||
|
* @param keyId the public key ID
|
||||||
|
* @return the Signature header value
|
||||||
|
*/
|
||||||
|
public String signRequest(String method, String targetUrl, String body, String privateKeyPem, String keyId) {
|
||||||
|
try {
|
||||||
|
java.net.URI uri = new java.net.URI(targetUrl);
|
||||||
|
String host = uri.getHost();
|
||||||
|
String path = uri.getPath();
|
||||||
|
if (uri.getQuery() != null) {
|
||||||
|
path += "?" + uri.getQuery();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build request-target
|
||||||
|
String requestTarget = method.toLowerCase() + " " + path;
|
||||||
|
|
||||||
|
// Calculate digest
|
||||||
|
java.security.MessageDigest digest = java.security.MessageDigest.getInstance("SHA-256");
|
||||||
|
byte[] hash = digest.digest(body.getBytes(StandardCharsets.UTF_8));
|
||||||
|
String digestValue = "SHA-256=" + Base64.getEncoder().encodeToString(hash);
|
||||||
|
|
||||||
|
// Get current date in RFC 1123 format
|
||||||
|
java.time.ZonedDateTime now = java.time.ZonedDateTime.now(java.time.ZoneOffset.UTC);
|
||||||
|
java.time.format.DateTimeFormatter formatter = java.time.format.DateTimeFormatter.RFC_1123_DATE_TIME;
|
||||||
|
String date = now.format(formatter);
|
||||||
|
|
||||||
|
// Build signing string
|
||||||
|
String signingString = String.format(
|
||||||
|
"(request-target): %s\nhost: %s\ndate: %s\ndigest: %s",
|
||||||
|
requestTarget, host, date, digestValue
|
||||||
|
);
|
||||||
|
|
||||||
|
// Sign
|
||||||
|
String signatureBase64 = sign(signingString, privateKeyPem);
|
||||||
|
|
||||||
|
// Build signature header
|
||||||
|
return String.format(
|
||||||
|
"keyId=\"%s\",algorithm=\"rsa-sha256\",headers=\"(request-target) host date digest\",signature=\"%s\"",
|
||||||
|
keyId, signatureBase64
|
||||||
|
);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to sign request", e);
|
||||||
|
throw new RuntimeException("Failed to sign request", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
259
src/main/java/org/operaton/fitpub/service/FederationService.java
Normal file
259
src/main/java/org/operaton/fitpub/service/FederationService.java
Normal file
|
|
@ -0,0 +1,259 @@
|
||||||
|
package org.operaton.fitpub.service;
|
||||||
|
|
||||||
|
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.operaton.fitpub.model.entity.Follow;
|
||||||
|
import org.operaton.fitpub.model.entity.RemoteActor;
|
||||||
|
import org.operaton.fitpub.model.entity.User;
|
||||||
|
import org.operaton.fitpub.repository.FollowRepository;
|
||||||
|
import org.operaton.fitpub.repository.RemoteActorRepository;
|
||||||
|
import org.operaton.fitpub.repository.UserRepository;
|
||||||
|
import org.operaton.fitpub.security.HttpSignatureValidator;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.http.HttpEntity;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
|
import org.springframework.http.HttpMethod;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import org.springframework.web.client.RestTemplate;
|
||||||
|
|
||||||
|
import java.net.URI;
|
||||||
|
import java.time.Instant;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for ActivityPub federation operations.
|
||||||
|
* Handles outbound activities and remote actor management.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class FederationService {
|
||||||
|
|
||||||
|
private final RemoteActorRepository remoteActorRepository;
|
||||||
|
private final FollowRepository followRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final HttpSignatureValidator signatureValidator;
|
||||||
|
private final ObjectMapper objectMapper;
|
||||||
|
private final RestTemplate restTemplate = new RestTemplate();
|
||||||
|
|
||||||
|
@Value("${fitpub.base-url}")
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch and cache a remote actor's information.
|
||||||
|
*
|
||||||
|
* @param actorUri the actor's URI
|
||||||
|
* @return the cached remote actor
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public RemoteActor fetchRemoteActor(String actorUri) {
|
||||||
|
log.info("Fetching remote actor: {}", actorUri);
|
||||||
|
|
||||||
|
// Check if we have a cached version
|
||||||
|
RemoteActor cached = remoteActorRepository.findByActorUri(actorUri).orElse(null);
|
||||||
|
if (cached != null && cached.getLastFetchedAt() != null &&
|
||||||
|
cached.getLastFetchedAt().isAfter(Instant.now().minusSeconds(3600))) {
|
||||||
|
log.debug("Using cached actor info for: {}", actorUri);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Fetch actor information
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.set("Accept", "application/activity+json");
|
||||||
|
HttpEntity<String> entity = new HttpEntity<>(headers);
|
||||||
|
|
||||||
|
ResponseEntity<Map> response = restTemplate.exchange(
|
||||||
|
actorUri,
|
||||||
|
HttpMethod.GET,
|
||||||
|
entity,
|
||||||
|
Map.class
|
||||||
|
);
|
||||||
|
|
||||||
|
Map<String, Object> actorData = response.getBody();
|
||||||
|
if (actorData == null) {
|
||||||
|
throw new RuntimeException("Empty actor response from: " + actorUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse actor data
|
||||||
|
String username = extractUsername(actorUri, actorData);
|
||||||
|
String domain = URI.create(actorUri).getHost();
|
||||||
|
String inboxUrl = (String) actorData.get("inbox");
|
||||||
|
String outboxUrl = (String) actorData.get("outbox");
|
||||||
|
String sharedInboxUrl = extractSharedInbox(actorData);
|
||||||
|
String publicKey = extractPublicKey(actorData);
|
||||||
|
String publicKeyId = extractPublicKeyId(actorData);
|
||||||
|
|
||||||
|
// Update or create remote actor
|
||||||
|
RemoteActor actor;
|
||||||
|
if (cached != null) {
|
||||||
|
actor = cached;
|
||||||
|
} else {
|
||||||
|
actor = new RemoteActor();
|
||||||
|
actor.setActorUri(actorUri);
|
||||||
|
}
|
||||||
|
|
||||||
|
actor.setUsername(username);
|
||||||
|
actor.setDomain(domain);
|
||||||
|
actor.setInboxUrl(inboxUrl);
|
||||||
|
actor.setOutboxUrl(outboxUrl);
|
||||||
|
actor.setSharedInboxUrl(sharedInboxUrl);
|
||||||
|
actor.setPublicKey(publicKey);
|
||||||
|
actor.setPublicKeyId(publicKeyId);
|
||||||
|
actor.setDisplayName((String) actorData.get("name"));
|
||||||
|
actor.setAvatarUrl(extractAvatarUrl(actorData));
|
||||||
|
actor.setSummary((String) actorData.get("summary"));
|
||||||
|
actor.setLastFetchedAt(Instant.now());
|
||||||
|
|
||||||
|
return remoteActorRepository.save(actor);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to fetch remote actor: {}", actorUri, e);
|
||||||
|
throw new RuntimeException("Failed to fetch remote actor: " + actorUri, e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an Accept activity in response to a Follow.
|
||||||
|
*
|
||||||
|
* @param follow the follow relationship
|
||||||
|
* @param localUser the local user being followed
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void sendAcceptActivity(Follow follow, User localUser) {
|
||||||
|
try {
|
||||||
|
RemoteActor remoteActor = fetchRemoteActor(follow.getFollowingActorUri());
|
||||||
|
|
||||||
|
String acceptId = baseUrl + "/activities/" + UUID.randomUUID();
|
||||||
|
String actorUri = baseUrl + "/users/" + localUser.getUsername();
|
||||||
|
|
||||||
|
Map<String, Object> acceptActivity = new HashMap<>();
|
||||||
|
acceptActivity.put("@context", "https://www.w3.org/ns/activitystreams");
|
||||||
|
acceptActivity.put("type", "Accept");
|
||||||
|
acceptActivity.put("id", acceptId);
|
||||||
|
acceptActivity.put("actor", actorUri);
|
||||||
|
acceptActivity.put("object", follow.getActivityId());
|
||||||
|
|
||||||
|
sendActivity(remoteActor.getInboxUrl(), acceptActivity, localUser);
|
||||||
|
log.info("Sent Accept activity to: {}", remoteActor.getActorUri());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to send Accept activity for follow: {}", follow.getId(), e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send an activity to a remote inbox.
|
||||||
|
*
|
||||||
|
* @param inboxUrl the remote inbox URL
|
||||||
|
* @param activity the activity to send
|
||||||
|
* @param sender the local user sending the activity
|
||||||
|
*/
|
||||||
|
public void sendActivity(String inboxUrl, Map<String, Object> activity, User sender) {
|
||||||
|
try {
|
||||||
|
String activityJson = objectMapper.writeValueAsString(activity);
|
||||||
|
|
||||||
|
HttpHeaders headers = new HttpHeaders();
|
||||||
|
headers.set("Content-Type", "application/activity+json");
|
||||||
|
headers.set("Accept", "application/activity+json");
|
||||||
|
|
||||||
|
// Add HTTP signature
|
||||||
|
String signature = signatureValidator.signRequest(
|
||||||
|
HttpMethod.POST.name(),
|
||||||
|
inboxUrl,
|
||||||
|
activityJson,
|
||||||
|
sender.getPrivateKey(),
|
||||||
|
baseUrl + "/users/" + sender.getUsername() + "#main-key"
|
||||||
|
);
|
||||||
|
headers.set("Signature", signature);
|
||||||
|
|
||||||
|
HttpEntity<String> entity = new HttpEntity<>(activityJson, headers);
|
||||||
|
|
||||||
|
ResponseEntity<String> response = restTemplate.postForEntity(inboxUrl, entity, String.class);
|
||||||
|
log.info("Sent activity to: {} - Status: {}", inboxUrl, response.getStatusCode());
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to send activity to: {}", inboxUrl, e);
|
||||||
|
throw new RuntimeException("Failed to send activity", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all follower inbox URLs for a local user.
|
||||||
|
*
|
||||||
|
* @param userId the local user's ID
|
||||||
|
* @return list of inbox URLs
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public List<String> getFollowerInboxes(UUID userId) {
|
||||||
|
User user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
|
||||||
|
|
||||||
|
String actorUri = baseUrl + "/users/" + user.getUsername();
|
||||||
|
List<Follow> followers = followRepository.findAcceptedFollowersByActorUri(actorUri);
|
||||||
|
|
||||||
|
return followers.stream()
|
||||||
|
.map(follow -> {
|
||||||
|
try {
|
||||||
|
RemoteActor actor = remoteActorRepository.findByActorUri(follow.getFollowingActorUri())
|
||||||
|
.orElseGet(() -> fetchRemoteActor(follow.getFollowingActorUri()));
|
||||||
|
return actor.getSharedInboxUrl() != null ? actor.getSharedInboxUrl() : actor.getInboxUrl();
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Failed to get inbox for follower: {}", follow.getFollowingActorUri(), e);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(inbox -> inbox != null)
|
||||||
|
.distinct()
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper methods
|
||||||
|
|
||||||
|
private String extractUsername(String actorUri, Map<String, Object> actorData) {
|
||||||
|
String preferredUsername = (String) actorData.get("preferredUsername");
|
||||||
|
if (preferredUsername != null) {
|
||||||
|
return preferredUsername;
|
||||||
|
}
|
||||||
|
// Fallback: extract from URI
|
||||||
|
return actorUri.substring(actorUri.lastIndexOf("/") + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractSharedInbox(Map<String, Object> actorData) {
|
||||||
|
Object endpoints = actorData.get("endpoints");
|
||||||
|
if (endpoints instanceof Map) {
|
||||||
|
return (String) ((Map<?, ?>) endpoints).get("sharedInbox");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractPublicKey(Map<String, Object> actorData) {
|
||||||
|
Object publicKey = actorData.get("publicKey");
|
||||||
|
if (publicKey instanceof Map) {
|
||||||
|
return (String) ((Map<?, ?>) publicKey).get("publicKeyPem");
|
||||||
|
}
|
||||||
|
throw new RuntimeException("No public key found in actor data");
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractPublicKeyId(Map<String, Object> actorData) {
|
||||||
|
Object publicKey = actorData.get("publicKey");
|
||||||
|
if (publicKey instanceof Map) {
|
||||||
|
return (String) ((Map<?, ?>) publicKey).get("id");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private String extractAvatarUrl(Map<String, Object> actorData) {
|
||||||
|
Object icon = actorData.get("icon");
|
||||||
|
if (icon instanceof Map) {
|
||||||
|
return (String) ((Map<?, ?>) icon).get("url");
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -139,6 +139,9 @@ public class FitFileService {
|
||||||
? title
|
? title
|
||||||
: generateTitle(parsedData);
|
: generateTitle(parsedData);
|
||||||
|
|
||||||
|
// Default to PUBLIC if visibility not specified
|
||||||
|
Activity.Visibility activityVisibility = visibility != null ? visibility : Activity.Visibility.PUBLIC;
|
||||||
|
|
||||||
return Activity.builder()
|
return Activity.builder()
|
||||||
.userId(userId)
|
.userId(userId)
|
||||||
.activityType(parsedData.getActivityType())
|
.activityType(parsedData.getActivityType())
|
||||||
|
|
@ -146,7 +149,7 @@ public class FitFileService {
|
||||||
.description(description)
|
.description(description)
|
||||||
.startedAt(parsedData.getStartTime())
|
.startedAt(parsedData.getStartTime())
|
||||||
.endedAt(parsedData.getEndTime())
|
.endedAt(parsedData.getEndTime())
|
||||||
.visibility(visibility)
|
.visibility(activityVisibility)
|
||||||
.totalDistance(parsedData.getTotalDistance())
|
.totalDistance(parsedData.getTotalDistance())
|
||||||
.totalDurationSeconds(parsedData.getTotalDuration() != null ? parsedData.getTotalDuration().getSeconds() : null)
|
.totalDurationSeconds(parsedData.getTotalDuration() != null ? parsedData.getTotalDuration().getSeconds() : null)
|
||||||
.elevationGain(parsedData.getElevationGain())
|
.elevationGain(parsedData.getElevationGain())
|
||||||
|
|
|
||||||
178
src/main/java/org/operaton/fitpub/service/InboxProcessor.java
Normal file
178
src/main/java/org/operaton/fitpub/service/InboxProcessor.java
Normal file
|
|
@ -0,0 +1,178 @@
|
||||||
|
package org.operaton.fitpub.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.operaton.fitpub.model.entity.Follow;
|
||||||
|
import org.operaton.fitpub.model.entity.RemoteActor;
|
||||||
|
import org.operaton.fitpub.model.entity.User;
|
||||||
|
import org.operaton.fitpub.repository.FollowRepository;
|
||||||
|
import org.operaton.fitpub.repository.UserRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Processes incoming ActivityPub activities in the inbox.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class InboxProcessor {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
private final FollowRepository followRepository;
|
||||||
|
private final FederationService federationService;
|
||||||
|
|
||||||
|
@Value("${fitpub.base-url}")
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process an incoming activity.
|
||||||
|
*
|
||||||
|
* @param username the local username
|
||||||
|
* @param activity the activity to process
|
||||||
|
*/
|
||||||
|
@Transactional
|
||||||
|
public void processActivity(String username, Map<String, Object> activity) {
|
||||||
|
String type = (String) activity.get("type");
|
||||||
|
log.info("Processing {} activity for user {}", type, username);
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case "Follow":
|
||||||
|
processFollow(username, activity);
|
||||||
|
break;
|
||||||
|
case "Undo":
|
||||||
|
processUndo(username, activity);
|
||||||
|
break;
|
||||||
|
case "Accept":
|
||||||
|
processAccept(username, activity);
|
||||||
|
break;
|
||||||
|
case "Create":
|
||||||
|
processCreate(username, activity);
|
||||||
|
break;
|
||||||
|
case "Like":
|
||||||
|
processLike(username, activity);
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
log.warn("Unhandled activity type: {}", type);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a Follow activity.
|
||||||
|
* Remote user wants to follow local user.
|
||||||
|
*/
|
||||||
|
private void processFollow(String username, Map<String, Object> activity) {
|
||||||
|
try {
|
||||||
|
String activityId = (String) activity.get("id");
|
||||||
|
String actor = (String) activity.get("actor");
|
||||||
|
String object = (String) activity.get("object");
|
||||||
|
|
||||||
|
// Verify the follow is for the correct local user
|
||||||
|
User localUser = userRepository.findByUsername(username)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("User not found: " + username));
|
||||||
|
|
||||||
|
String expectedObjectUri = baseUrl + "/users/" + username;
|
||||||
|
if (!object.equals(expectedObjectUri)) {
|
||||||
|
log.warn("Follow object mismatch. Expected: {}, Got: {}", expectedObjectUri, object);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch remote actor information
|
||||||
|
RemoteActor remoteActor = federationService.fetchRemoteActor(actor);
|
||||||
|
|
||||||
|
// Check if follow already exists
|
||||||
|
Follow existing = followRepository.findByActivityId(activityId).orElse(null);
|
||||||
|
if (existing != null) {
|
||||||
|
log.debug("Follow already processed: {}", activityId);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create follow relationship (as the object of the follow, from remote actor's perspective)
|
||||||
|
// Here we store that the remote actor is following our local user
|
||||||
|
// Note: We're storing it from the perspective of "who is following whom"
|
||||||
|
Follow follow = Follow.builder()
|
||||||
|
.followerId(null) // Remote actor, so no local user ID
|
||||||
|
.followingActorUri(expectedObjectUri) // The local user being followed
|
||||||
|
.status(Follow.FollowStatus.ACCEPTED) // Auto-accept for now
|
||||||
|
.activityId(activityId)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
followRepository.save(follow);
|
||||||
|
|
||||||
|
// Send Accept activity
|
||||||
|
federationService.sendAcceptActivity(follow, localUser);
|
||||||
|
|
||||||
|
log.info("Processed Follow from {} for user {}", actor, username);
|
||||||
|
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error processing Follow activity", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process an Undo activity (e.g., unfollow).
|
||||||
|
*/
|
||||||
|
private void processUndo(String username, Map<String, Object> activity) {
|
||||||
|
try {
|
||||||
|
Object object = activity.get("object");
|
||||||
|
if (object instanceof Map) {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> undoObject = (Map<String, Object>) object;
|
||||||
|
String type = (String) undoObject.get("type");
|
||||||
|
|
||||||
|
if ("Follow".equals(type)) {
|
||||||
|
String activityId = (String) undoObject.get("id");
|
||||||
|
Follow follow = followRepository.findByActivityId(activityId).orElse(null);
|
||||||
|
if (follow != null) {
|
||||||
|
followRepository.delete(follow);
|
||||||
|
log.info("Processed Undo Follow: {}", activityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error processing Undo activity", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process an Accept activity (e.g., follow request accepted).
|
||||||
|
*/
|
||||||
|
private void processAccept(String username, Map<String, Object> activity) {
|
||||||
|
try {
|
||||||
|
Object object = activity.get("object");
|
||||||
|
if (object instanceof Map) {
|
||||||
|
@SuppressWarnings("unchecked")
|
||||||
|
Map<String, Object> acceptObject = (Map<String, Object>) object;
|
||||||
|
String activityId = (String) acceptObject.get("id");
|
||||||
|
|
||||||
|
Follow follow = followRepository.findByActivityId(activityId).orElse(null);
|
||||||
|
if (follow != null && follow.getStatus() == Follow.FollowStatus.PENDING) {
|
||||||
|
follow.setStatus(Follow.FollowStatus.ACCEPTED);
|
||||||
|
followRepository.save(follow);
|
||||||
|
log.info("Follow request accepted: {}", activityId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.error("Error processing Accept activity", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a Create activity (e.g., new post).
|
||||||
|
*/
|
||||||
|
private void processCreate(String username, Map<String, Object> activity) {
|
||||||
|
// TODO: Implement Create activity processing
|
||||||
|
log.debug("Received Create activity for user {}", username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process a Like activity.
|
||||||
|
*/
|
||||||
|
private void processLike(String username, Map<String, Object> activity) {
|
||||||
|
// TODO: Implement Like activity processing
|
||||||
|
log.debug("Received Like activity for user {}", username);
|
||||||
|
}
|
||||||
|
}
|
||||||
176
src/main/java/org/operaton/fitpub/service/TimelineService.java
Normal file
176
src/main/java/org/operaton/fitpub/service/TimelineService.java
Normal file
|
|
@ -0,0 +1,176 @@
|
||||||
|
package org.operaton.fitpub.service;
|
||||||
|
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.operaton.fitpub.model.dto.TimelineActivityDTO;
|
||||||
|
import org.operaton.fitpub.model.entity.Activity;
|
||||||
|
import org.operaton.fitpub.model.entity.Follow;
|
||||||
|
import org.operaton.fitpub.model.entity.User;
|
||||||
|
import org.operaton.fitpub.repository.ActivityRepository;
|
||||||
|
import org.operaton.fitpub.repository.FollowRepository;
|
||||||
|
import org.operaton.fitpub.repository.UserRepository;
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
|
import org.springframework.data.domain.Page;
|
||||||
|
import org.springframework.data.domain.PageImpl;
|
||||||
|
import org.springframework.data.domain.Pageable;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.UUID;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for managing timelines.
|
||||||
|
* Provides federated timeline of activities from followed users.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
@Slf4j
|
||||||
|
public class TimelineService {
|
||||||
|
|
||||||
|
private final ActivityRepository activityRepository;
|
||||||
|
private final FollowRepository followRepository;
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
@Value("${fitpub.base-url}")
|
||||||
|
private String baseUrl;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the federated timeline for a user.
|
||||||
|
* Includes public activities from:
|
||||||
|
* - The user's own activities
|
||||||
|
* - Activities from users they follow (local users only for now)
|
||||||
|
*
|
||||||
|
* @param userId the authenticated user's ID
|
||||||
|
* @param pageable pagination parameters
|
||||||
|
* @return page of timeline activities
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<TimelineActivityDTO> getFederatedTimeline(UUID userId, Pageable pageable) {
|
||||||
|
log.debug("Fetching federated timeline for user: {}", userId);
|
||||||
|
|
||||||
|
User currentUser = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
|
||||||
|
|
||||||
|
// Get list of user IDs that the current user follows
|
||||||
|
List<UUID> followedUserIds = getFollowedLocalUserIds(userId);
|
||||||
|
|
||||||
|
// Include the current user's own activities
|
||||||
|
followedUserIds.add(userId);
|
||||||
|
|
||||||
|
// Fetch public and followers-only activities from followed users
|
||||||
|
Page<Activity> activities = activityRepository.findByUserIdInAndVisibilityInOrderByStartedAtDesc(
|
||||||
|
followedUserIds,
|
||||||
|
List.of(Activity.Visibility.PUBLIC, Activity.Visibility.FOLLOWERS),
|
||||||
|
pageable
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert to DTOs
|
||||||
|
List<TimelineActivityDTO> timelineActivities = activities.getContent().stream()
|
||||||
|
.map(activity -> {
|
||||||
|
User activityUser = userRepository.findById(activity.getUserId()).orElse(null);
|
||||||
|
if (activityUser == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return TimelineActivityDTO.fromActivity(
|
||||||
|
activity,
|
||||||
|
activityUser.getUsername(),
|
||||||
|
activityUser.getDisplayName() != null ? activityUser.getDisplayName() : activityUser.getUsername(),
|
||||||
|
activityUser.getAvatarUrl()
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.filter(dto -> dto != null)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
return new PageImpl<>(timelineActivities, pageable, activities.getTotalElements());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get the public timeline.
|
||||||
|
* Shows all public activities from all users.
|
||||||
|
*
|
||||||
|
* @param pageable pagination parameters
|
||||||
|
* @return page of timeline activities
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<TimelineActivityDTO> getPublicTimeline(Pageable pageable) {
|
||||||
|
log.debug("Fetching public timeline");
|
||||||
|
|
||||||
|
// Fetch all public activities
|
||||||
|
Page<Activity> activities = activityRepository.findByVisibilityOrderByStartedAtDesc(
|
||||||
|
Activity.Visibility.PUBLIC,
|
||||||
|
pageable
|
||||||
|
);
|
||||||
|
|
||||||
|
// Convert to DTOs
|
||||||
|
List<TimelineActivityDTO> timelineActivities = activities.getContent().stream()
|
||||||
|
.map(activity -> {
|
||||||
|
User activityUser = userRepository.findById(activity.getUserId()).orElse(null);
|
||||||
|
if (activityUser == null) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return TimelineActivityDTO.fromActivity(
|
||||||
|
activity,
|
||||||
|
activityUser.getUsername(),
|
||||||
|
activityUser.getDisplayName() != null ? activityUser.getDisplayName() : activityUser.getUsername(),
|
||||||
|
activityUser.getAvatarUrl()
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.filter(dto -> dto != null)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
return new PageImpl<>(timelineActivities, pageable, activities.getTotalElements());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get user's own timeline (their activities only).
|
||||||
|
*
|
||||||
|
* @param userId the user's ID
|
||||||
|
* @param pageable pagination parameters
|
||||||
|
* @return page of timeline activities
|
||||||
|
*/
|
||||||
|
@Transactional(readOnly = true)
|
||||||
|
public Page<TimelineActivityDTO> getUserTimeline(UUID userId, Pageable pageable) {
|
||||||
|
log.debug("Fetching user timeline for: {}", userId);
|
||||||
|
|
||||||
|
User user = userRepository.findById(userId)
|
||||||
|
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
|
||||||
|
|
||||||
|
Page<Activity> activities = activityRepository.findByUserIdOrderByStartedAtDesc(userId, pageable);
|
||||||
|
|
||||||
|
List<TimelineActivityDTO> timelineActivities = activities.getContent().stream()
|
||||||
|
.map(activity -> TimelineActivityDTO.fromActivity(
|
||||||
|
activity,
|
||||||
|
user.getUsername(),
|
||||||
|
user.getDisplayName() != null ? user.getDisplayName() : user.getUsername(),
|
||||||
|
user.getAvatarUrl()
|
||||||
|
))
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
return new PageImpl<>(timelineActivities, pageable, activities.getTotalElements());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get IDs of local users that the given user follows.
|
||||||
|
*
|
||||||
|
* @param userId the user's ID
|
||||||
|
* @return list of followed local user IDs
|
||||||
|
*/
|
||||||
|
private List<UUID> getFollowedLocalUserIds(UUID userId) {
|
||||||
|
List<Follow> follows = followRepository.findAcceptedFollowingByUserId(userId);
|
||||||
|
List<UUID> followedUserIds = new ArrayList<>();
|
||||||
|
|
||||||
|
for (Follow follow : follows) {
|
||||||
|
// Check if the followed actor is a local user
|
||||||
|
String actorUri = follow.getFollowingActorUri();
|
||||||
|
if (actorUri.startsWith(baseUrl + "/users/")) {
|
||||||
|
String username = actorUri.substring((baseUrl + "/users/").length());
|
||||||
|
userRepository.findByUsername(username).ifPresent(user -> followedUserIds.add(user.getId()));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return followedUserIds;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -14,7 +14,7 @@ spring:
|
||||||
|
|
||||||
jpa:
|
jpa:
|
||||||
hibernate:
|
hibernate:
|
||||||
ddl-auto: update
|
ddl-auto: validate
|
||||||
properties:
|
properties:
|
||||||
hibernate:
|
hibernate:
|
||||||
dialect: org.hibernate.dialect.PostgreSQLDialect
|
dialect: org.hibernate.dialect.PostgreSQLDialect
|
||||||
|
|
@ -22,6 +22,13 @@ spring:
|
||||||
use_sql_comments: true
|
use_sql_comments: true
|
||||||
show-sql: false
|
show-sql: false
|
||||||
|
|
||||||
|
flyway:
|
||||||
|
enabled: true
|
||||||
|
baseline-on-migrate: true
|
||||||
|
locations: classpath:db/migration
|
||||||
|
schemas: public
|
||||||
|
validate-on-migrate: true
|
||||||
|
|
||||||
servlet:
|
servlet:
|
||||||
multipart:
|
multipart:
|
||||||
max-file-size: 50MB
|
max-file-size: 50MB
|
||||||
|
|
@ -72,3 +79,21 @@ server:
|
||||||
error:
|
error:
|
||||||
include-message: always
|
include-message: always
|
||||||
include-binding-errors: 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
|
||||||
|
|
|
||||||
7
src/main/resources/db/migration/V1__enable_postgis.sql
Normal file
7
src/main/resources/db/migration/V1__enable_postgis.sql
Normal 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();
|
||||||
28
src/main/resources/db/migration/V2__create_users_table.sql
Normal file
28
src/main/resources/db/migration/V2__create_users_table.sql
Normal 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)';
|
||||||
|
|
@ -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';
|
||||||
|
|
@ -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';
|
||||||
31
src/main/resources/db/migration/V5__create_follows_table.sql
Normal file
31
src/main/resources/db/migration/V5__create_follows_table.sql
Normal 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';
|
||||||
|
|
@ -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';
|
||||||
207
src/main/resources/static/css/fitpub.css
Normal file
207
src/main/resources/static/css/fitpub.css
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
308
src/main/resources/static/js/auth.js
Normal file
308
src/main/resources/static/js/auth.js
Normal 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;
|
||||||
468
src/main/resources/static/js/fitpub.js
Normal file
468
src/main/resources/static/js/fitpub.js
Normal 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: '© <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
|
||||||
|
};
|
||||||
443
src/main/resources/templates/activities/detail.html
Normal file
443
src/main/resources/templates/activities/detail.html
Normal 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>
|
||||||
330
src/main/resources/templates/activities/edit.html
Normal file
330
src/main/resources/templates/activities/edit.html
Normal 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>
|
||||||
339
src/main/resources/templates/activities/list.html
Normal file
339
src/main/resources/templates/activities/list.html
Normal 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>
|
||||||
420
src/main/resources/templates/activities/upload.html
Normal file
420
src/main/resources/templates/activities/upload.html
Normal 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>
|
||||||
199
src/main/resources/templates/auth/login.html
Normal file
199
src/main/resources/templates/auth/login.html
Normal 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>
|
||||||
273
src/main/resources/templates/auth/register.html
Normal file
273
src/main/resources/templates/auth/register.html
Normal 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>
|
||||||
32
src/main/resources/templates/index-simple.html
Normal file
32
src/main/resources/templates/index-simple.html
Normal 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>
|
||||||
139
src/main/resources/templates/index.html
Normal file
139
src/main/resources/templates/index.html
Normal 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>
|
||||||
210
src/main/resources/templates/layout.html
Normal file
210
src/main/resources/templates/layout.html
Normal 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>© 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>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue