More vibin
This commit is contained in:
parent
1901daf5ce
commit
c1729a629d
47 changed files with 5754 additions and 41 deletions
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