mirror of
https://github.com/sstent/GarminSync.git
synced 2026-01-26 17:12:50 +00:00
updated web interface - v3
This commit is contained in:
138
garminsync/web/static/activities.js
Normal file
138
garminsync/web/static/activities.js
Normal file
@@ -0,0 +1,138 @@
|
||||
class ActivitiesPage {
|
||||
constructor() {
|
||||
this.currentPage = 1;
|
||||
this.pageSize = 25;
|
||||
this.totalPages = 1;
|
||||
this.activities = [];
|
||||
this.filters = {};
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.loadActivities();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
async loadActivities() {
|
||||
try {
|
||||
const params = new URLSearchParams({
|
||||
page: this.currentPage,
|
||||
per_page: this.pageSize,
|
||||
...this.filters
|
||||
});
|
||||
|
||||
const response = await fetch(`/api/activities?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load activities');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
this.activities = data.activities;
|
||||
this.totalPages = Math.ceil(data.total / this.pageSize);
|
||||
|
||||
this.renderTable();
|
||||
this.renderPagination();
|
||||
} catch (error) {
|
||||
console.error('Failed to load activities:', error);
|
||||
this.showError('Failed to load activities');
|
||||
}
|
||||
}
|
||||
|
||||
renderTable() {
|
||||
const tbody = document.getElementById('activities-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
if (!this.activities || this.activities.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6">No activities found</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
tbody.innerHTML = '';
|
||||
|
||||
this.activities.forEach((activity, index) => {
|
||||
const row = this.createTableRow(activity, index);
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
createTableRow(activity, index) {
|
||||
const row = document.createElement('tr');
|
||||
row.className = index % 2 === 0 ? 'row-even' : 'row-odd';
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${Utils.formatDate(activity.start_time)}</td>
|
||||
<td>${activity.activity_type || '-'}</td>
|
||||
<td>${Utils.formatDuration(activity.duration)}</td>
|
||||
<td>${Utils.formatDistance(activity.distance)}</td>
|
||||
<td>${activity.max_heart_rate || '-'}</td>
|
||||
<td>${Utils.formatPower(activity.avg_power)}</td>
|
||||
`;
|
||||
|
||||
return row;
|
||||
}
|
||||
|
||||
renderPagination() {
|
||||
const pagination = document.getElementById('pagination');
|
||||
if (!pagination) return;
|
||||
|
||||
if (this.totalPages <= 1) {
|
||||
pagination.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let paginationHtml = '';
|
||||
|
||||
// Previous button
|
||||
paginationHtml += `
|
||||
<li class="${this.currentPage === 1 ? 'disabled' : ''}">
|
||||
<a href="#" onclick="activitiesPage.changePage(${this.currentPage - 1}); return false;">Previous</a>
|
||||
</li>
|
||||
`;
|
||||
|
||||
// Page numbers
|
||||
for (let i = 1; i <= this.totalPages; i++) {
|
||||
if (i === 1 || i === this.totalPages || (i >= this.currentPage - 2 && i <= this.currentPage + 2)) {
|
||||
paginationHtml += `
|
||||
<li class="${i === this.currentPage ? 'active' : ''}">
|
||||
<a href="#" onclick="activitiesPage.changePage(${i}); return false;">${i}</a>
|
||||
</li>
|
||||
`;
|
||||
} else if (i === this.currentPage - 3 || i === this.currentPage + 3) {
|
||||
paginationHtml += '<li><span>...</span></li>';
|
||||
}
|
||||
}
|
||||
|
||||
// Next button
|
||||
paginationHtml += `
|
||||
<li class="${this.currentPage === this.totalPages ? 'disabled' : ''}">
|
||||
<a href="#" onclick="activitiesPage.changePage(${this.currentPage + 1}); return false;">Next</a>
|
||||
</li>
|
||||
`;
|
||||
|
||||
pagination.innerHTML = paginationHtml;
|
||||
}
|
||||
|
||||
changePage(page) {
|
||||
if (page < 1 || page > this.totalPages) return;
|
||||
this.currentPage = page;
|
||||
this.loadActivities();
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// We can add filter event listeners here if needed
|
||||
}
|
||||
|
||||
showError(message) {
|
||||
const tbody = document.getElementById('activities-tbody');
|
||||
if (tbody) {
|
||||
tbody.innerHTML = `<tr><td colspan="6">Error: ${message}</td></tr>`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize activities page when DOM is loaded
|
||||
let activitiesPage;
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
activitiesPage = new ActivitiesPage();
|
||||
});
|
||||
@@ -1,104 +1,3 @@
|
||||
// Auto-refresh dashboard data
|
||||
setInterval(updateStatus, 30000); // Every 30 seconds
|
||||
|
||||
async function updateStatus() {
|
||||
try {
|
||||
const response = await fetch('/api/status');
|
||||
const data = await response.json();
|
||||
|
||||
// Update sync status
|
||||
const syncStatus = document.getElementById('sync-status');
|
||||
const statusBadge = data.daemon.running ?
|
||||
'<span class="badge badge-success">Running</span>' :
|
||||
'<span class="badge badge-danger">Stopped</span>';
|
||||
|
||||
syncStatus.innerHTML = `${statusBadge}`;
|
||||
|
||||
// Update daemon status
|
||||
document.getElementById('daemon-status').innerHTML = `
|
||||
<p>Status: ${statusBadge}</p>
|
||||
<p>Last Run: ${data.daemon.last_run || 'Never'}</p>
|
||||
<p>Next Run: ${data.daemon.next_run || 'Not scheduled'}</p>
|
||||
<p>Schedule: ${data.daemon.schedule || 'Not configured'}</p>
|
||||
`;
|
||||
|
||||
// Update recent logs
|
||||
const logsHtml = data.recent_logs.map(log => `
|
||||
<div class="log-entry">
|
||||
<small class="text-muted">${log.timestamp}</small>
|
||||
<span class="badge badge-${log.status === 'success' ? 'success' : 'danger'}">
|
||||
${log.status}
|
||||
</span>
|
||||
${log.operation}: ${log.message || ''}
|
||||
${log.activities_downloaded > 0 ? `Downloaded ${log.activities_downloaded} activities` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
document.getElementById('recent-logs').innerHTML = logsHtml;
|
||||
|
||||
} catch (error) {
|
||||
console.error('Failed to update status:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async function triggerSync() {
|
||||
try {
|
||||
await fetch('/api/sync/trigger', { method: 'POST' });
|
||||
alert('Sync triggered successfully');
|
||||
updateStatus();
|
||||
} catch (error) {
|
||||
alert('Failed to trigger sync');
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDaemon() {
|
||||
try {
|
||||
const statusResponse = await fetch('/api/status');
|
||||
const statusData = await statusResponse.json();
|
||||
const isRunning = statusData.daemon.running;
|
||||
|
||||
if (isRunning) {
|
||||
await fetch('/api/daemon/stop', { method: 'POST' });
|
||||
alert('Daemon stopped successfully');
|
||||
} else {
|
||||
await fetch('/api/daemon/start', { method: 'POST' });
|
||||
alert('Daemon started successfully');
|
||||
}
|
||||
|
||||
updateStatus();
|
||||
} catch (error) {
|
||||
alert('Failed to toggle daemon: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
// Schedule form handling
|
||||
document.getElementById('schedule-form')?.addEventListener('submit', async function(e) {
|
||||
e.preventDefault();
|
||||
|
||||
const enabled = document.getElementById('schedule-enabled').checked;
|
||||
const cronSchedule = document.getElementById('cron-schedule').value;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/schedule', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
enabled: enabled,
|
||||
cron_schedule: cronSchedule
|
||||
})
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
alert('Schedule updated successfully');
|
||||
updateStatus();
|
||||
} else {
|
||||
const error = await response.json();
|
||||
alert(`Error: ${error.detail}`);
|
||||
}
|
||||
} catch (error) {
|
||||
alert('Failed to update schedule: ' + error.message);
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize on page load
|
||||
document.addEventListener('DOMContentLoaded', updateStatus);
|
||||
// This file is deprecated and no longer used.
|
||||
// The functionality has been moved to home.js, activities.js, and logs.js
|
||||
// This file is kept for backward compatibility but is empty.
|
||||
|
||||
@@ -1,37 +1 @@
|
||||
// Initialize the activity progress chart
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Fetch activity stats from the API
|
||||
fetch('/api/activities/stats')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
// Create doughnut chart
|
||||
const ctx = document.getElementById('activityChart').getContext('2d');
|
||||
const chart = new Chart(ctx, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
labels: ['Downloaded', 'Missing'],
|
||||
datasets: [{
|
||||
data: [data.downloaded, data.missing],
|
||||
backgroundColor: ['#28a745', '#dc3545'],
|
||||
borderWidth: 1
|
||||
}]
|
||||
},
|
||||
options: {
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
plugins: {
|
||||
legend: {
|
||||
position: 'top',
|
||||
},
|
||||
title: {
|
||||
display: true,
|
||||
text: 'Activity Status'
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error fetching activity stats:', error);
|
||||
});
|
||||
});
|
||||
// This file is deprecated and no longer used.
|
||||
|
||||
200
garminsync/web/static/components.css
Normal file
200
garminsync/web/static/components.css
Normal file
@@ -0,0 +1,200 @@
|
||||
/* Table Styling */
|
||||
.activities-table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.activities-table thead {
|
||||
background-color: #000;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.activities-table th {
|
||||
padding: 12px 16px;
|
||||
text-align: left;
|
||||
font-weight: 600;
|
||||
border-right: 1px solid #333;
|
||||
}
|
||||
|
||||
.activities-table th:last-child {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.activities-table td {
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.activities-table .row-even {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
.activities-table .row-odd {
|
||||
background-color: #ffffff;
|
||||
}
|
||||
|
||||
.activities-table tr:hover {
|
||||
background-color: #e9ecef;
|
||||
}
|
||||
|
||||
/* Sync Button Styling */
|
||||
.btn-primary.btn-large {
|
||||
width: 100%;
|
||||
padding: 15px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
border-radius: var(--border-radius);
|
||||
background: linear-gradient(135deg, #007bff 0%, #0056b3 100%);
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-primary.btn-large:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,123,255,0.3);
|
||||
}
|
||||
|
||||
.btn-primary.btn-large:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
/* Statistics Card */
|
||||
.statistics-card .stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.statistics-card .stat-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.statistics-card label {
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.statistics-card span {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Pagination */
|
||||
.pagination-container {
|
||||
margin-top: 20px;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.pagination li {
|
||||
margin: 0 5px;
|
||||
}
|
||||
|
||||
.pagination a {
|
||||
display: block;
|
||||
padding: 8px 12px;
|
||||
text-decoration: none;
|
||||
color: var(--primary-color);
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 4px;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.pagination a:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.pagination .active a {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
border-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.pagination .disabled a {
|
||||
color: #ccc;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Form elements */
|
||||
.form-group {
|
||||
margin-bottom: 15px;
|
||||
}
|
||||
|
||||
.form-group label {
|
||||
display: block;
|
||||
margin-bottom: 5px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: var(--border-radius);
|
||||
font-family: var(--font-family);
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-control:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px rgba(0,123,255,0.25);
|
||||
}
|
||||
|
||||
/* Badges */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 4px 8px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.badge-success {
|
||||
background-color: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-error {
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.badge-warning {
|
||||
background-color: var(--warning-color);
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
/* Table responsive */
|
||||
.table-container {
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
/* Activities table card */
|
||||
.activities-table-card {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.activities-table-card .card-header {
|
||||
padding: 20px;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Activities container */
|
||||
.activities-container {
|
||||
margin-top: 20px;
|
||||
}
|
||||
144
garminsync/web/static/home.js
Normal file
144
garminsync/web/static/home.js
Normal file
@@ -0,0 +1,144 @@
|
||||
class HomePage {
|
||||
constructor() {
|
||||
this.logSocket = null;
|
||||
this.statsRefreshInterval = null;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.attachEventListeners();
|
||||
this.setupRealTimeUpdates();
|
||||
this.loadInitialData();
|
||||
}
|
||||
|
||||
attachEventListeners() {
|
||||
const syncButton = document.getElementById('sync-now-btn');
|
||||
if (syncButton) {
|
||||
syncButton.addEventListener('click', () => this.triggerSync());
|
||||
}
|
||||
}
|
||||
|
||||
async triggerSync() {
|
||||
const btn = document.getElementById('sync-now-btn');
|
||||
const status = document.getElementById('sync-status');
|
||||
|
||||
if (!btn || !status) return;
|
||||
|
||||
btn.disabled = true;
|
||||
btn.innerHTML = '<i class="icon-loading"></i> Syncing...';
|
||||
status.textContent = 'Sync in progress...';
|
||||
status.className = 'sync-status syncing';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/sync/trigger', {method: 'POST'});
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
status.textContent = 'Sync completed successfully';
|
||||
status.className = 'sync-status success';
|
||||
this.updateStats();
|
||||
} else {
|
||||
throw new Error(result.detail || 'Sync failed');
|
||||
}
|
||||
} catch (error) {
|
||||
status.textContent = `Sync failed: ${error.message}`;
|
||||
status.className = 'sync-status error';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.innerHTML = '<i class="icon-sync"></i> Sync Now';
|
||||
|
||||
// Reset status message after 5 seconds
|
||||
setTimeout(() => {
|
||||
if (status.className.includes('success')) {
|
||||
status.textContent = 'Ready to sync';
|
||||
status.className = 'sync-status';
|
||||
}
|
||||
}, 5000);
|
||||
}
|
||||
}
|
||||
|
||||
setupRealTimeUpdates() {
|
||||
// Poll for log updates every 5 seconds during active operations
|
||||
this.startLogPolling();
|
||||
|
||||
// Update stats every 30 seconds
|
||||
this.statsRefreshInterval = setInterval(() => {
|
||||
this.updateStats();
|
||||
}, 30000);
|
||||
}
|
||||
|
||||
async startLogPolling() {
|
||||
// For now, we'll update logs every 10 seconds
|
||||
setInterval(() => {
|
||||
this.updateLogs();
|
||||
}, 10000);
|
||||
}
|
||||
|
||||
async updateStats() {
|
||||
try {
|
||||
const response = await fetch('/api/dashboard/stats');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch stats');
|
||||
}
|
||||
|
||||
const stats = await response.json();
|
||||
|
||||
const totalEl = document.getElementById('total-activities');
|
||||
const downloadedEl = document.getElementById('downloaded-activities');
|
||||
const missingEl = document.getElementById('missing-activities');
|
||||
|
||||
if (totalEl) totalEl.textContent = stats.total;
|
||||
if (downloadedEl) downloadedEl.textContent = stats.downloaded;
|
||||
if (missingEl) missingEl.textContent = stats.missing;
|
||||
} catch (error) {
|
||||
console.error('Failed to update stats:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async updateLogs() {
|
||||
try {
|
||||
const response = await fetch('/api/status');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch logs');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
this.renderLogs(data.recent_logs);
|
||||
} catch (error) {
|
||||
console.error('Failed to update logs:', error);
|
||||
}
|
||||
}
|
||||
|
||||
renderLogs(logs) {
|
||||
const logContent = document.getElementById('log-content');
|
||||
if (!logContent) return;
|
||||
|
||||
if (!logs || logs.length === 0) {
|
||||
logContent.innerHTML = '<div class="log-entry">No recent activity</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const logsHtml = logs.map(log => `
|
||||
<div class="log-entry">
|
||||
<span class="timestamp">${Utils.formatTimestamp(log.timestamp)}</span>
|
||||
<span class="status ${log.status === 'success' ? 'success' : 'error'}">
|
||||
${log.status}
|
||||
</span>
|
||||
${log.operation}: ${log.message || ''}
|
||||
${log.activities_downloaded > 0 ? `Downloaded ${log.activities_downloaded} activities` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
|
||||
logContent.innerHTML = logsHtml;
|
||||
}
|
||||
|
||||
async loadInitialData() {
|
||||
// Load initial logs
|
||||
await this.updateLogs();
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize home page when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
new HomePage();
|
||||
});
|
||||
@@ -4,118 +4,176 @@ const logsPerPage = 20;
|
||||
let totalLogs = 0;
|
||||
let currentFilters = {};
|
||||
|
||||
// Initialize logs page
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadLogs();
|
||||
});
|
||||
class LogsPage {
|
||||
constructor() {
|
||||
this.currentPage = 1;
|
||||
this.init();
|
||||
}
|
||||
|
||||
init() {
|
||||
this.loadLogs();
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
async loadLogs() {
|
||||
try {
|
||||
// Build query string from filters
|
||||
const params = new URLSearchParams({
|
||||
page: this.currentPage,
|
||||
per_page: logsPerPage,
|
||||
...currentFilters
|
||||
}).toString();
|
||||
|
||||
async function loadLogs() {
|
||||
try {
|
||||
// Build query string from filters
|
||||
const params = new URLSearchParams({
|
||||
page: currentPage,
|
||||
perPage: logsPerPage,
|
||||
...currentFilters
|
||||
}).toString();
|
||||
|
||||
const response = await fetch(`/api/logs?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch logs');
|
||||
const response = await fetch(`/api/logs?${params}`);
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch logs');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
totalLogs = data.total;
|
||||
this.renderLogs(data.logs);
|
||||
this.renderPagination();
|
||||
} catch (error) {
|
||||
console.error('Error loading logs:', error);
|
||||
Utils.showError('Failed to load logs: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
renderLogs(logs) {
|
||||
const tbody = document.getElementById('logs-tbody');
|
||||
if (!tbody) return;
|
||||
|
||||
tbody.innerHTML = '';
|
||||
|
||||
if (!logs || logs.length === 0) {
|
||||
tbody.innerHTML = '<tr><td colspan="6">No logs found</td></tr>';
|
||||
return;
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
totalLogs = data.total;
|
||||
renderLogs(data.logs);
|
||||
renderPagination();
|
||||
} catch (error) {
|
||||
console.error('Error loading logs:', error);
|
||||
alert('Failed to load logs: ' + error.message);
|
||||
logs.forEach(log => {
|
||||
const row = document.createElement('tr');
|
||||
row.className = 'row-odd'; // For alternating row colors
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${Utils.formatTimestamp(log.timestamp)}</td>
|
||||
<td>${log.operation}</td>
|
||||
<td><span class="badge badge-${log.status === 'success' ? 'success' :
|
||||
log.status === 'error' ? 'error' :
|
||||
'warning'}">${log.status}</span></td>
|
||||
<td>${log.message || ''}</td>
|
||||
<td>${log.activities_processed}</td>
|
||||
<td>${log.activities_downloaded}</td>
|
||||
`;
|
||||
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderLogs(logs) {
|
||||
const tbody = document.getElementById('logs-tbody');
|
||||
tbody.innerHTML = '';
|
||||
|
||||
logs.forEach(log => {
|
||||
const row = document.createElement('tr');
|
||||
renderPagination() {
|
||||
const totalPages = Math.ceil(totalLogs / logsPerPage);
|
||||
const pagination = document.getElementById('pagination');
|
||||
if (!pagination) return;
|
||||
|
||||
row.innerHTML = `
|
||||
<td>${log.timestamp}</td>
|
||||
<td>${log.operation}</td>
|
||||
<td><span class="badge badge-${log.status === 'success' ? 'success' :
|
||||
log.status === 'error' ? 'danger' :
|
||||
'warning'}">${log.status}</span></td>
|
||||
<td>${log.message || ''}</td>
|
||||
<td>${log.activities_processed}</td>
|
||||
<td>${log.activities_downloaded}</td>
|
||||
if (totalPages <= 1) {
|
||||
pagination.innerHTML = '';
|
||||
return;
|
||||
}
|
||||
|
||||
let paginationHtml = '';
|
||||
|
||||
// Previous button
|
||||
paginationHtml += `
|
||||
<li class="${this.currentPage === 1 ? 'disabled' : ''}">
|
||||
<a href="#" onclick="logsPage.changePage(${this.currentPage - 1}); return false;">Previous</a>
|
||||
</li>
|
||||
`;
|
||||
|
||||
tbody.appendChild(row);
|
||||
});
|
||||
}
|
||||
|
||||
function renderPagination() {
|
||||
const totalPages = Math.ceil(totalLogs / logsPerPage);
|
||||
const pagination = document.getElementById('pagination');
|
||||
pagination.innerHTML = '';
|
||||
|
||||
// Previous button
|
||||
const prevLi = document.createElement('li');
|
||||
prevLi.className = `page-item ${currentPage === 1 ? 'disabled' : ''}`;
|
||||
prevLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage - 1})">Previous</a>`;
|
||||
pagination.appendChild(prevLi);
|
||||
|
||||
// Page numbers
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
const li = document.createElement('li');
|
||||
li.className = `page-item ${i === currentPage ? 'active' : ''}`;
|
||||
li.innerHTML = `<a class="page-link" href="#" onclick="changePage(${i})">${i}</a>`;
|
||||
pagination.appendChild(li);
|
||||
// Page numbers
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
if (i === 1 || i === totalPages || (i >= this.currentPage - 2 && i <= this.currentPage + 2)) {
|
||||
paginationHtml += `
|
||||
<li class="${i === this.currentPage ? 'active' : ''}">
|
||||
<a href="#" onclick="logsPage.changePage(${i}); return false;">${i}</a>
|
||||
</li>
|
||||
`;
|
||||
} else if (i === this.currentPage - 3 || i === this.currentPage + 3) {
|
||||
paginationHtml += '<li><span>...</span></li>';
|
||||
}
|
||||
}
|
||||
|
||||
// Next button
|
||||
paginationHtml += `
|
||||
<li class="${this.currentPage === totalPages ? 'disabled' : ''}">
|
||||
<a href="#" onclick="logsPage.changePage(${this.currentPage + 1}); return false;">Next</a>
|
||||
</li>
|
||||
`;
|
||||
|
||||
pagination.innerHTML = paginationHtml;
|
||||
}
|
||||
|
||||
// Next button
|
||||
const nextLi = document.createElement('li');
|
||||
nextLi.className = `page-item ${currentPage === totalPages ? 'disabled' : ''}`;
|
||||
nextLi.innerHTML = `<a class="page-link" href="#" onclick="changePage(${currentPage + 1})">Next</a>`;
|
||||
pagination.appendChild(nextLi);
|
||||
changePage(page) {
|
||||
if (page < 1 || page > Math.ceil(totalLogs / logsPerPage)) return;
|
||||
this.currentPage = page;
|
||||
this.loadLogs();
|
||||
}
|
||||
|
||||
refreshLogs() {
|
||||
this.currentPage = 1;
|
||||
this.loadLogs();
|
||||
}
|
||||
|
||||
applyFilters() {
|
||||
currentFilters = {
|
||||
status: document.getElementById('status-filter').value,
|
||||
operation: document.getElementById('operation-filter').value,
|
||||
date: document.getElementById('date-filter').value
|
||||
};
|
||||
|
||||
this.currentPage = 1;
|
||||
this.loadLogs();
|
||||
}
|
||||
|
||||
async clearLogs() {
|
||||
if (!confirm('Are you sure you want to clear all logs? This cannot be undone.')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/logs', { method: 'DELETE' });
|
||||
if (response.ok) {
|
||||
Utils.showSuccess('Logs cleared successfully');
|
||||
this.refreshLogs();
|
||||
} else {
|
||||
throw new Error('Failed to clear logs');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error clearing logs:', error);
|
||||
Utils.showError('Failed to clear logs: ' + error.message);
|
||||
}
|
||||
}
|
||||
|
||||
setupEventListeners() {
|
||||
// Event listeners are handled in the global functions below
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize logs page when DOM is loaded
|
||||
let logsPage;
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
logsPage = new LogsPage();
|
||||
});
|
||||
|
||||
// Global functions for backward compatibility with HTML onclick attributes
|
||||
function changePage(page) {
|
||||
if (page < 1 || page > Math.ceil(totalLogs / logsPerPage)) return;
|
||||
currentPage = page;
|
||||
loadLogs();
|
||||
if (logsPage) logsPage.changePage(page);
|
||||
}
|
||||
|
||||
function refreshLogs() {
|
||||
currentPage = 1;
|
||||
loadLogs();
|
||||
if (logsPage) logsPage.refreshLogs();
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
currentFilters = {
|
||||
status: document.getElementById('status-filter').value,
|
||||
operation: document.getElementById('operation-filter').value,
|
||||
date: document.getElementById('date-filter').value
|
||||
};
|
||||
|
||||
currentPage = 1;
|
||||
loadLogs();
|
||||
if (logsPage) logsPage.applyFilters();
|
||||
}
|
||||
|
||||
async function clearLogs() {
|
||||
if (!confirm('Are you sure you want to clear all logs? This cannot be undone.')) return;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/logs', { method: 'DELETE' });
|
||||
if (response.ok) {
|
||||
alert('Logs cleared successfully');
|
||||
refreshLogs();
|
||||
} else {
|
||||
throw new Error('Failed to clear logs');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error clearing logs:', error);
|
||||
alert('Failed to clear logs: ' + error.message);
|
||||
}
|
||||
function clearLogs() {
|
||||
if (logsPage) logsPage.clearLogs();
|
||||
}
|
||||
|
||||
52
garminsync/web/static/navigation.js
Normal file
52
garminsync/web/static/navigation.js
Normal file
@@ -0,0 +1,52 @@
|
||||
class Navigation {
|
||||
constructor() {
|
||||
this.currentPage = this.getCurrentPage();
|
||||
this.render();
|
||||
}
|
||||
|
||||
getCurrentPage() {
|
||||
return window.location.pathname === '/activities' ? 'activities' : 'home';
|
||||
}
|
||||
|
||||
render() {
|
||||
const nav = document.querySelector('.navigation');
|
||||
if (nav) {
|
||||
nav.innerHTML = this.getNavigationHTML();
|
||||
this.attachEventListeners();
|
||||
}
|
||||
}
|
||||
|
||||
getNavigationHTML() {
|
||||
return `
|
||||
<nav class="nav-tabs">
|
||||
<button class="nav-tab ${this.currentPage === 'home' ? 'active' : ''}"
|
||||
data-page="home">Home</button>
|
||||
<button class="nav-tab ${this.currentPage === 'activities' ? 'active' : ''}"
|
||||
data-page="activities">Activities</button>
|
||||
</nav>
|
||||
`;
|
||||
}
|
||||
|
||||
attachEventListeners() {
|
||||
const tabs = document.querySelectorAll('.nav-tab');
|
||||
tabs.forEach(tab => {
|
||||
tab.addEventListener('click', (e) => {
|
||||
const page = e.target.getAttribute('data-page');
|
||||
this.navigateToPage(page);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
navigateToPage(page) {
|
||||
if (page === 'home') {
|
||||
window.location.href = '/';
|
||||
} else if (page === 'activities') {
|
||||
window.location.href = '/activities';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Initialize navigation when DOM is loaded
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
new Navigation();
|
||||
});
|
||||
78
garminsync/web/static/responsive.css
Normal file
78
garminsync/web/static/responsive.css
Normal file
@@ -0,0 +1,78 @@
|
||||
/* Mobile-first responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.layout-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.activities-table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.activities-table th,
|
||||
.activities-table td {
|
||||
padding: 8px 10px;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 10px;
|
||||
}
|
||||
|
||||
.card {
|
||||
padding: 15px;
|
||||
}
|
||||
|
||||
.btn {
|
||||
padding: 8px 15px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
padding: 12px 20px;
|
||||
font-size: 15px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.activities-table {
|
||||
display: block;
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stat-item {
|
||||
flex-direction: column;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.log-content {
|
||||
padding: 5px;
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.pagination a {
|
||||
padding: 6px 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.form-control {
|
||||
padding: 8px;
|
||||
font-size: 14px;
|
||||
}
|
||||
}
|
||||
@@ -1,32 +1,268 @@
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
background-color: #f8f9fa;
|
||||
/* CSS Variables for consistent theming */
|
||||
:root {
|
||||
--primary-color: #007bff;
|
||||
--secondary-color: #6c757d;
|
||||
--success-color: #28a745;
|
||||
--danger-color: #dc3545;
|
||||
--warning-color: #ffc107;
|
||||
--light-gray: #f8f9fa;
|
||||
--dark-gray: #343a40;
|
||||
--border-radius: 8px;
|
||||
--box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
--font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
}
|
||||
|
||||
/* Reset and base styles */
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
background-color: #f5f7fa;
|
||||
color: #333;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
/* CSS Grid Layout System */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 0 20px;
|
||||
}
|
||||
|
||||
.layout-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 300px 1fr;
|
||||
gap: 20px;
|
||||
min-height: calc(100vh - 60px);
|
||||
}
|
||||
|
||||
/* Modern Card Components */
|
||||
.card {
|
||||
background: white;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--box-shadow);
|
||||
padding: 20px;
|
||||
margin-bottom: 20px;
|
||||
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
|
||||
}
|
||||
|
||||
.card-header {
|
||||
font-weight: bold;
|
||||
background-color: #f1f1f1;
|
||||
font-weight: 600;
|
||||
font-size: 1.2rem;
|
||||
margin-bottom: 15px;
|
||||
padding-bottom: 10px;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
/* Navigation */
|
||||
.navigation {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
display: flex;
|
||||
background: white;
|
||||
border-radius: var(--border-radius);
|
||||
box-shadow: var(--box-shadow);
|
||||
padding: 5px;
|
||||
}
|
||||
|
||||
.nav-tab {
|
||||
flex: 1;
|
||||
padding: 12px 20px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
border-radius: var(--border-radius);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.nav-tab:hover {
|
||||
background-color: #f0f0f0;
|
||||
}
|
||||
|
||||
.nav-tab.active {
|
||||
background-color: var(--primary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Buttons */
|
||||
.btn {
|
||||
margin-right: 5px;
|
||||
padding: 10px 20px;
|
||||
border: none;
|
||||
border-radius: var(--border-radius);
|
||||
cursor: pointer;
|
||||
font-weight: 500;
|
||||
transition: all 0.2s ease;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
background: linear-gradient(135deg, var(--primary-color) 0%, #0056b3 100%);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled) {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 4px 12px rgba(0,123,255,0.3);
|
||||
}
|
||||
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-secondary {
|
||||
background-color: var(--secondary-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-success {
|
||||
background-color: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-danger {
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.btn-warning {
|
||||
background-color: var(--warning-color);
|
||||
color: #212529;
|
||||
}
|
||||
|
||||
.btn-large {
|
||||
padding: 15px 25px;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
/* Icons */
|
||||
.icon-sync::before {
|
||||
content: "↻";
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
.icon-loading::before {
|
||||
content: "⏳";
|
||||
margin-right: 8px;
|
||||
}
|
||||
|
||||
/* Status display */
|
||||
.sync-status {
|
||||
margin-top: 15px;
|
||||
padding: 10px;
|
||||
border-radius: var(--border-radius);
|
||||
text-align: center;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.sync-status.syncing {
|
||||
background-color: #e3f2fd;
|
||||
color: var(--primary-color);
|
||||
}
|
||||
|
||||
.sync-status.success {
|
||||
background-color: #e8f5e9;
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.sync-status.error {
|
||||
background-color: #ffebee;
|
||||
color: var(--danger-color);
|
||||
}
|
||||
|
||||
/* Statistics */
|
||||
.stat-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 10px;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #eee;
|
||||
}
|
||||
|
||||
.stat-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stat-item label {
|
||||
font-weight: 500;
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.stat-item span {
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* Log display */
|
||||
.log-content {
|
||||
max-height: 400px;
|
||||
overflow-y: auto;
|
||||
padding: 10px;
|
||||
background-color: #f8f9fa;
|
||||
border-radius: var(--border-radius);
|
||||
font-family: monospace;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
|
||||
.log-entry {
|
||||
margin-bottom: 10px;
|
||||
padding: 5px;
|
||||
margin-bottom: 8px;
|
||||
padding: 8px;
|
||||
border-left: 3px solid #ddd;
|
||||
background-color: white;
|
||||
border-radius: 0 var(--border-radius) var(--border-radius) 0;
|
||||
}
|
||||
|
||||
.log-entry .badge-success {
|
||||
background-color: #28a745;
|
||||
.log-entry .timestamp {
|
||||
font-size: 0.8rem;
|
||||
color: #666;
|
||||
margin-right: 10px;
|
||||
}
|
||||
|
||||
.log-entry .badge-error {
|
||||
background-color: #dc3545;
|
||||
.log-entry .status {
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 0.8rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.log-entry .status.success {
|
||||
background-color: var(--success-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.log-entry .status.error {
|
||||
background-color: var(--danger-color);
|
||||
color: white;
|
||||
}
|
||||
|
||||
/* Responsive Design */
|
||||
@media (max-width: 768px) {
|
||||
.layout-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
order: 2;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
order: 1;
|
||||
}
|
||||
|
||||
.nav-tabs {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.container {
|
||||
padding: 0 10px;
|
||||
}
|
||||
}
|
||||
|
||||
50
garminsync/web/static/utils.js
Normal file
50
garminsync/web/static/utils.js
Normal file
@@ -0,0 +1,50 @@
|
||||
// Utility functions for the GarminSync application
|
||||
|
||||
class Utils {
|
||||
// Format date for display
|
||||
static formatDate(dateStr) {
|
||||
if (!dateStr) return '-';
|
||||
return new Date(dateStr).toLocaleDateString();
|
||||
}
|
||||
|
||||
// Format duration from seconds to HH:MM
|
||||
static formatDuration(seconds) {
|
||||
if (!seconds) return '-';
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const minutes = Math.floor((seconds % 3600) / 60);
|
||||
return `${hours}:${minutes.toString().padStart(2, '0')}`;
|
||||
}
|
||||
|
||||
// Format distance from meters to kilometers
|
||||
static formatDistance(meters) {
|
||||
if (!meters) return '-';
|
||||
return `${(meters / 1000).toFixed(1)} km`;
|
||||
}
|
||||
|
||||
// Format power from watts
|
||||
static formatPower(watts) {
|
||||
return watts ? `${Math.round(watts)}W` : '-';
|
||||
}
|
||||
|
||||
// Show error message
|
||||
static showError(message) {
|
||||
console.error(message);
|
||||
// In a real implementation, you might want to show this in the UI
|
||||
alert(`Error: ${message}`);
|
||||
}
|
||||
|
||||
// Show success message
|
||||
static showSuccess(message) {
|
||||
console.log(message);
|
||||
// In a real implementation, you might want to show this in the UI
|
||||
}
|
||||
|
||||
// Format timestamp for log entries
|
||||
static formatTimestamp(timestamp) {
|
||||
if (!timestamp) return '';
|
||||
return new Date(timestamp).toLocaleString();
|
||||
}
|
||||
}
|
||||
|
||||
// Make Utils available globally
|
||||
window.Utils = Utils;
|
||||
Reference in New Issue
Block a user