1253 lines
35 KiB
Vue
1253 lines
35 KiB
Vue
<template>
|
||
<div class="calendar-page">
|
||
<ModernNavbar />
|
||
|
||
<div class="calendar-content">
|
||
<!-- Page Header -->
|
||
<div class="section-header">
|
||
<div>
|
||
<h1 class="section-title">Workout Calendar</h1>
|
||
<p class="section-description">Plan and track your training schedule</p>
|
||
</div>
|
||
<button @click="showAddWorkout = true" class="btn-modern btn-modern-primary btn-modern-large">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||
</svg>
|
||
Add Workout
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Month Navigation -->
|
||
<div class="month-navigation">
|
||
<button @click="previousMonth" class="btn-modern btn-modern-secondary">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="15 18 9 12 15 6"></polyline>
|
||
</svg>
|
||
Previous
|
||
</button>
|
||
<h2 class="current-month-display">{{ monthYear }}</h2>
|
||
<button @click="nextMonth" class="btn-modern btn-modern-secondary">
|
||
Next
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="9 18 15 12 9 6"></polyline>
|
||
</svg>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Add Workout Modal -->
|
||
<div v-if="showAddWorkout" class="modal-overlay" @click.self="showAddWorkout = false">
|
||
<div class="modal-content-modern">
|
||
<div class="modal-header-modern">
|
||
<h3 class="modal-title">Add New Workout</h3>
|
||
<button @click="showAddWorkout = false" class="btn-close-modern">✕</button>
|
||
</div>
|
||
|
||
<form @submit.prevent="handleAddWorkout" class="form-modern">
|
||
<!-- Title -->
|
||
<div class="form-group-modern">
|
||
<label class="form-label-modern">Workout Title *</label>
|
||
<input
|
||
v-model="newWorkout.title"
|
||
type="text"
|
||
class="form-input-modern"
|
||
placeholder="e.g., Morning Endurance Ride, Tempo Run, etc."
|
||
required
|
||
/>
|
||
<span class="form-helper-text">Give your workout a descriptive name</span>
|
||
</div>
|
||
|
||
<!-- Date and Type Row -->
|
||
<div class="form-row-modern">
|
||
<div class="form-group-modern">
|
||
<label class="form-label-modern">Scheduled Date *</label>
|
||
<input
|
||
v-model="newWorkout.scheduled_date"
|
||
type="date"
|
||
class="form-input-modern"
|
||
required
|
||
/>
|
||
<span class="form-helper-text">When do you plan to do this?</span>
|
||
</div>
|
||
<div class="form-group-modern">
|
||
<label class="form-label-modern">Workout Type</label>
|
||
<select v-model="newWorkout.type" class="form-input-modern">
|
||
<option value="">Select type</option>
|
||
<option v-for="type in workoutTypes" :key="type.id" :value="type.name">
|
||
{{ type.icon }} {{ type.name }}
|
||
</option>
|
||
</select>
|
||
<span class="form-helper-text">Choose activity type</span>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Duration -->
|
||
<div class="form-group-modern">
|
||
<label class="form-label-modern">Duration (minutes)</label>
|
||
<input
|
||
v-model.number="newWorkout.duration"
|
||
type="number"
|
||
class="form-input-modern"
|
||
step="1"
|
||
min="1"
|
||
max="999"
|
||
placeholder="60"
|
||
/>
|
||
<span class="form-helper-text">How long will this workout take?</span>
|
||
</div>
|
||
|
||
<!-- Description -->
|
||
<div class="form-group-modern">
|
||
<label class="form-label-modern">Description</label>
|
||
<textarea
|
||
v-model="newWorkout.description"
|
||
class="form-input-modern"
|
||
placeholder="Add workout notes, intervals, goals, or any other details..."
|
||
rows="4"
|
||
></textarea>
|
||
<span class="form-helper-text">Optional notes about your workout</span>
|
||
</div>
|
||
|
||
<!-- File Upload Section -->
|
||
<div class="form-group-modern">
|
||
<label class="form-label-modern">Import Workout File (Optional)</label>
|
||
<div class="info-box-info mb-sm">
|
||
<strong>Tip:</strong> Upload FIT or ZWO files from TrainerRoad, Zwift, or other platforms to automatically populate workout details and structure.
|
||
</div>
|
||
<div class="file-upload-area">
|
||
<input
|
||
@change="handleFileUpload"
|
||
type="file"
|
||
accept=".fit,.zwo"
|
||
id="workout-file"
|
||
class="file-input-hidden"
|
||
/>
|
||
<label for="workout-file" class="file-upload-label">
|
||
<svg width="32" height="32" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M21 15V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V15" stroke-linecap="round" stroke-linejoin="round"/>
|
||
<polyline points="17 8 12 3 7 8" stroke-linecap="round" stroke-linejoin="round"/>
|
||
<line x1="12" y1="3" x2="12" y2="15" stroke-linecap="round" stroke-linejoin="round"/>
|
||
</svg>
|
||
<div class="file-upload-text">
|
||
<strong>{{ uploadedFileName || 'Click to upload file' }}</strong>
|
||
<span v-if="!uploadedFileName">or drag and drop</span>
|
||
</div>
|
||
<span class="file-upload-hint">Supports FIT and ZWO formats</span>
|
||
</label>
|
||
</div>
|
||
|
||
<div v-if="fileParseError" class="form-error-text">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<circle cx="12" cy="12" r="10"></circle>
|
||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||
</svg>
|
||
{{ fileParseError }}
|
||
</div>
|
||
|
||
<div v-if="parsedFileData" class="file-parse-success">
|
||
<div class="parse-success-header">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
|
||
<polyline points="22 4 12 14.01 9 11.01"></polyline>
|
||
</svg>
|
||
<strong>File parsed successfully!</strong>
|
||
</div>
|
||
<div class="parse-success-details">
|
||
<div v-if="parsedFileData.title" class="parse-detail">
|
||
<span class="parse-label">Title:</span>
|
||
<span class="parse-value">{{ parsedFileData.title }}</span>
|
||
</div>
|
||
<div v-if="parsedFileData.duration" class="parse-detail">
|
||
<span class="parse-label">Duration:</span>
|
||
<span class="parse-value">{{ formatDuration(parsedFileData.duration) }}</span>
|
||
</div>
|
||
<div v-if="parsedFileData.segments" class="parse-detail">
|
||
<span class="parse-label">Segments:</span>
|
||
<span class="parse-value">{{ parsedFileData.segments.length }}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Form Actions -->
|
||
<div class="form-actions-modern">
|
||
<button type="button" @click="showAddWorkout = false" class="btn-modern btn-modern-secondary">
|
||
Cancel
|
||
</button>
|
||
<button type="button" @click="resetForm" class="btn-modern btn-modern-secondary">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="1 4 1 10 7 10"></polyline>
|
||
<path d="M3.51 15a9 9 0 1 0 2.13-9.36L1 10"></path>
|
||
</svg>
|
||
Reset
|
||
</button>
|
||
<button type="submit" :disabled="loading" class="btn-modern btn-modern-primary">
|
||
<svg v-if="!loading" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="20 6 9 17 4 12"></polyline>
|
||
</svg>
|
||
{{ loading ? 'Adding...' : 'Add Workout' }}
|
||
</button>
|
||
</div>
|
||
</form>
|
||
|
||
<div v-if="error" class="error-message-modern">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<circle cx="12" cy="12" r="10"></circle>
|
||
<line x1="12" y1="8" x2="12" y2="12"></line>
|
||
<line x1="12" y1="16" x2="12.01" y2="16"></line>
|
||
</svg>
|
||
{{ error }}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Calendar Grid -->
|
||
<div class="card-modern calendar-card">
|
||
<div class="calendar-grid">
|
||
<div class="calendar-header-row">
|
||
<div class="day-header">Sunday</div>
|
||
<div class="day-header">Monday</div>
|
||
<div class="day-header">Tuesday</div>
|
||
<div class="day-header">Wednesday</div>
|
||
<div class="day-header">Thursday</div>
|
||
<div class="day-header">Friday</div>
|
||
<div class="day-header">Saturday</div>
|
||
</div>
|
||
|
||
<div class="calendar-days-grid">
|
||
<div
|
||
v-for="day in calendarDays"
|
||
:key="day.date"
|
||
class="calendar-day-modern"
|
||
:class="{
|
||
'other-month': !day.isCurrentMonth,
|
||
'today': day.isToday,
|
||
'has-workouts': day.workouts.length > 0
|
||
}"
|
||
>
|
||
<div class="day-number-modern">
|
||
{{ day.day }}
|
||
<span v-if="day.isToday" class="today-badge">Today</span>
|
||
</div>
|
||
<div class="day-workouts-modern">
|
||
<div
|
||
v-for="workout in day.workouts"
|
||
:key="workout.id"
|
||
class="workout-badge-modern"
|
||
:style="{ borderLeftColor: getWorkoutColor(workout.type) }"
|
||
@click="selectWorkout(workout)"
|
||
>
|
||
<div class="workout-badge-header">
|
||
<span class="workout-badge-icon">{{ getWorkoutIcon(workout.type) }}</span>
|
||
<span class="workout-badge-title">{{ workout.title }}</span>
|
||
</div>
|
||
<div class="workout-badge-footer">
|
||
<span class="workout-badge-duration">{{ workout.duration }}min</span>
|
||
<span :class="['status-pill', `status-pill-${workout.status}`]">
|
||
{{ workout.status }}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Workout Details Component -->
|
||
<WorkoutDetail
|
||
v-if="selectedWorkout"
|
||
:workout="selectedWorkout"
|
||
@close="selectedWorkout = null"
|
||
@updated="loadWorkouts"
|
||
@deleted="loadWorkouts"
|
||
/>
|
||
|
||
<!-- Upcoming Workouts List -->
|
||
<div class="card-modern">
|
||
<div class="card-modern-header">
|
||
<div>
|
||
<h3 class="card-modern-title">Upcoming Workouts</h3>
|
||
<p class="card-modern-subtitle">Your next 5 scheduled workouts</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div v-if="upcomingWorkouts.length > 0" class="upcoming-workouts-table">
|
||
<table class="table-modern">
|
||
<thead>
|
||
<tr>
|
||
<th>Date</th>
|
||
<th>Workout</th>
|
||
<th>Type</th>
|
||
<th>Duration</th>
|
||
<th>Status</th>
|
||
<th></th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
<tr
|
||
v-for="workout in upcomingWorkouts.slice(0, 5)"
|
||
:key="workout.id"
|
||
class="workout-row"
|
||
@click="selectWorkout(workout)"
|
||
>
|
||
<td class="workout-date-cell">
|
||
<div class="date-display">
|
||
<span class="date-day">{{ formatDateDay(workout.scheduled_date) }}</span>
|
||
<span class="date-month">{{ formatDateMonth(workout.scheduled_date) }}</span>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<div class="workout-title-cell">
|
||
<span class="workout-icon">{{ getWorkoutIcon(workout.type) }}</span>
|
||
<strong>{{ workout.title }}</strong>
|
||
</div>
|
||
</td>
|
||
<td>
|
||
<span v-if="workout.type" class="workout-type-badge">{{ workout.type }}</span>
|
||
<span v-else class="text-secondary">-</span>
|
||
</td>
|
||
<td>
|
||
<span class="duration-display">{{ workout.duration }} min</span>
|
||
</td>
|
||
<td>
|
||
<span :class="['status-pill', `status-pill-${workout.status}`]">
|
||
{{ workout.status }}
|
||
</span>
|
||
</td>
|
||
<td>
|
||
<button class="btn-action" @click.stop="selectWorkout(workout)">
|
||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<polyline points="9 18 15 12 9 6"></polyline>
|
||
</svg>
|
||
</button>
|
||
</td>
|
||
</tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<div v-else class="empty-state-modern">
|
||
<div class="empty-state-icon">📅</div>
|
||
<h3 class="empty-state-title">No Upcoming Workouts</h3>
|
||
<p class="empty-state-description">
|
||
You don't have any workouts scheduled yet. Click the "Add Workout" button to plan your first training session!
|
||
</p>
|
||
<button @click="showAddWorkout = true" class="btn-modern btn-modern-primary">
|
||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||
<line x1="12" y1="5" x2="12" y2="19"></line>
|
||
<line x1="5" y1="12" x2="19" y2="12"></line>
|
||
</svg>
|
||
Add Your First Workout
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import api from '@/services/api'
|
||
import ModernNavbar from '@/components/ModernNavbar.vue'
|
||
import WorkoutDetail from './WorkoutDetail.vue'
|
||
import { parseFITFile, parseZWOFile } from '@/utils/workoutFileParser'
|
||
|
||
const currentDate = ref(new Date())
|
||
const workouts = ref([])
|
||
const selectedWorkout = ref(null)
|
||
const showAddWorkout = ref(false)
|
||
const loading = ref(false)
|
||
const error = ref('')
|
||
const fileParseError = ref('')
|
||
const workoutTypes = ref([])
|
||
const uploadedFileName = ref('')
|
||
const parsedFileData = ref(null)
|
||
|
||
const newWorkout = ref({
|
||
title: '',
|
||
description: '',
|
||
type: '',
|
||
scheduled_date: new Date().toISOString().split('T')[0],
|
||
duration: 60,
|
||
workout_data: null,
|
||
file_type: null,
|
||
})
|
||
|
||
const monthYear = computed(() => {
|
||
return currentDate.value.toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
|
||
})
|
||
|
||
const calendarDays = computed(() => {
|
||
const year = currentDate.value.getFullYear()
|
||
const month = currentDate.value.getMonth()
|
||
|
||
const firstDay = new Date(year, month, 1)
|
||
const lastDay = new Date(year, month + 1, 0)
|
||
const startDate = new Date(firstDay)
|
||
startDate.setDate(startDate.getDate() - firstDay.getDay())
|
||
|
||
const days = []
|
||
let currentDay = new Date(startDate)
|
||
|
||
while (currentDay <= lastDay || currentDay.getDay() !== 0) {
|
||
const dateStr = currentDay.toISOString().split('T')[0]
|
||
const dayWorkouts = workouts.value.filter(w =>
|
||
w.scheduled_date.split('T')[0] === dateStr
|
||
)
|
||
|
||
days.push({
|
||
date: dateStr,
|
||
day: currentDay.getDate(),
|
||
isCurrentMonth: currentDay.getMonth() === month,
|
||
isToday: isToday(currentDay),
|
||
workouts: dayWorkouts,
|
||
})
|
||
|
||
currentDay.setDate(currentDay.getDate() + 1)
|
||
}
|
||
|
||
return days
|
||
})
|
||
|
||
const upcomingWorkouts = computed(() => {
|
||
return workouts.value
|
||
.filter(w => new Date(w.scheduled_date) >= new Date())
|
||
.sort((a, b) => new Date(a.scheduled_date) - new Date(b.scheduled_date))
|
||
})
|
||
|
||
onMounted(async () => {
|
||
// Debug logging
|
||
console.log('=== Frontend Debug Info ===')
|
||
console.log('Stored access_token:', localStorage.getItem('access_token'))
|
||
console.log('Stored refresh_token:', localStorage.getItem('refresh_token'))
|
||
console.log('API Base URL:', process.env.VUE_APP_API_URL)
|
||
console.log('NODE_ENV:', process.env.NODE_ENV)
|
||
console.log('========================')
|
||
|
||
await loadWorkoutTypes()
|
||
await loadWorkouts()
|
||
})
|
||
|
||
async function loadWorkouts() {
|
||
try {
|
||
const { data } = await api.get('/api/protected/workouts')
|
||
workouts.value = data || []
|
||
} catch (err) {
|
||
console.error('Failed to load workouts:', err)
|
||
error.value = 'Failed to load workouts'
|
||
}
|
||
}
|
||
|
||
async function loadWorkoutTypes() {
|
||
try {
|
||
const { data } = await api.get('/api/protected/workout-types')
|
||
console.log('Loaded workout types:', data)
|
||
workoutTypes.value = data || []
|
||
} catch (err) {
|
||
console.error('Failed to load workout types:', err)
|
||
// Set default types if API fails
|
||
workoutTypes.value = [
|
||
{ id: 1, name: 'Cycling', icon: '🚴', color: '#667eea' },
|
||
{ id: 2, name: 'Running', icon: '🏃', color: '#f59e0b' },
|
||
{ id: 3, name: 'Swimming', icon: '🏊', color: '#06b6d4' },
|
||
{ id: 4, name: 'Strength', icon: '💪', color: '#8b5cf6' },
|
||
]
|
||
}
|
||
}
|
||
|
||
async function handleFileUpload(event) {
|
||
const file = event.target.files?.[0]
|
||
if (!file) return
|
||
|
||
fileParseError.value = ''
|
||
parsedFileData.value = null
|
||
uploadedFileName.value = file.name
|
||
|
||
try {
|
||
const fileExtension = file.name.split('.').pop()?.toLowerCase()
|
||
let parsed
|
||
|
||
if (fileExtension === 'fit') {
|
||
const fileBuffer = await file.arrayBuffer()
|
||
parsed = await parseFITFile(fileBuffer)
|
||
} else if (fileExtension === 'zwo') {
|
||
parsed = await parseZWOFile(file)
|
||
} else {
|
||
throw new Error('Unsupported file format. Please use FIT or ZWO files.')
|
||
}
|
||
|
||
if (parsed) {
|
||
parsedFileData.value = parsed
|
||
newWorkout.value.workout_data = parsed
|
||
newWorkout.value.file_type = fileExtension
|
||
|
||
// Auto-populate fields from file
|
||
if (parsed.title && !newWorkout.value.title) {
|
||
newWorkout.value.title = parsed.title
|
||
}
|
||
|
||
// Update duration from parsed file (convert seconds to minutes)
|
||
if (parsed.duration) {
|
||
newWorkout.value.duration = Math.round(parsed.duration / 60)
|
||
}
|
||
|
||
if (parsed.description && !newWorkout.value.description) {
|
||
newWorkout.value.description = parsed.description
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('File parsing error:', err)
|
||
fileParseError.value = `Error parsing file: ${err.message}`
|
||
uploadedFileName.value = ''
|
||
newWorkout.value.workout_data = null
|
||
newWorkout.value.file_type = null
|
||
}
|
||
}
|
||
|
||
async function handleAddWorkout() {
|
||
if (!newWorkout.value.title.trim()) {
|
||
error.value = 'Please enter a workout title'
|
||
return
|
||
}
|
||
|
||
if (!newWorkout.value.scheduled_date) {
|
||
error.value = 'Please select a date'
|
||
return
|
||
}
|
||
|
||
loading.value = true
|
||
error.value = ''
|
||
|
||
try {
|
||
const payload = {
|
||
title: newWorkout.value.title,
|
||
description: newWorkout.value.description,
|
||
type: newWorkout.value.type || null,
|
||
scheduled_date: newWorkout.value.scheduled_date,
|
||
duration: newWorkout.value.duration,
|
||
workout_data: newWorkout.value.workout_data,
|
||
file_type: newWorkout.value.file_type,
|
||
}
|
||
|
||
console.log('=== Sending Payload ===')
|
||
console.log('Payload:', payload)
|
||
console.log('API URL:', api.defaults.baseURL)
|
||
console.log('Full URL:', api.defaults.baseURL + '/api/protected/workouts')
|
||
console.log('======================')
|
||
|
||
const { data } = await api.post('/api/protected/workouts', payload)
|
||
workouts.value.push(data)
|
||
showAddWorkout.value = false
|
||
resetForm()
|
||
} catch (err) {
|
||
console.error('=== Full Error ===')
|
||
console.error('Error object:', err)
|
||
console.error('Response:', err.response)
|
||
console.error('Request:', err.request)
|
||
console.error('Message:', err.message)
|
||
console.error('=================')
|
||
|
||
error.value = err.response?.data?.error || 'Failed to add workout'
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
function resetForm() {
|
||
newWorkout.value = {
|
||
title: '',
|
||
description: '',
|
||
type: '',
|
||
scheduled_date: new Date().toISOString().split('T')[0],
|
||
duration: 60,
|
||
workout_data: null,
|
||
file_type: null,
|
||
}
|
||
uploadedFileName.value = ''
|
||
parsedFileData.value = null
|
||
fileParseError.value = ''
|
||
error.value = ''
|
||
|
||
// Reset file input
|
||
const fileInput = document.getElementById('workout-file')
|
||
if (fileInput) {
|
||
fileInput.value = ''
|
||
}
|
||
}
|
||
|
||
function previousMonth() {
|
||
currentDate.value = new Date(currentDate.value.getFullYear(), currentDate.value.getMonth() - 1)
|
||
}
|
||
|
||
function nextMonth() {
|
||
currentDate.value = new Date(currentDate.value.getFullYear(), currentDate.value.getMonth() + 1)
|
||
}
|
||
|
||
function selectWorkout(workout) {
|
||
selectedWorkout.value = workout
|
||
}
|
||
|
||
function isToday(date) {
|
||
const today = new Date()
|
||
return date.getDate() === today.getDate() &&
|
||
date.getMonth() === today.getMonth() &&
|
||
date.getFullYear() === today.getFullYear()
|
||
}
|
||
|
||
function formatDate(dateStr) {
|
||
return new Date(dateStr).toLocaleDateString('en-US', {
|
||
month: 'short',
|
||
day: 'numeric',
|
||
year: 'numeric',
|
||
})
|
||
}
|
||
|
||
function formatDuration(seconds) {
|
||
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 getWorkoutColor(type) {
|
||
const typeObj = workoutTypes.value.find(t => t.name === type)
|
||
return typeObj?.color || '#667eea'
|
||
}
|
||
|
||
function getWorkoutIcon(type) {
|
||
const typeObj = workoutTypes.value.find(t => t.name === type)
|
||
return typeObj?.icon || '🏋️'
|
||
}
|
||
|
||
function formatDateDay(dateStr) {
|
||
return new Date(dateStr).toLocaleDateString('en-US', { day: 'numeric' })
|
||
}
|
||
|
||
function formatDateMonth(dateStr) {
|
||
return new Date(dateStr).toLocaleDateString('en-US', { month: 'short' })
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* Calendar Page - Modern TrainingPeaks/TrainerRoad Inspired Design */
|
||
.calendar-page {
|
||
min-height: 100vh;
|
||
background: var(--color-background);
|
||
}
|
||
|
||
.calendar-content {
|
||
max-width: 1600px;
|
||
margin: 0 auto;
|
||
padding: var(--spacing-2xl) var(--spacing-lg);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-2xl);
|
||
}
|
||
|
||
/* Month Navigation */
|
||
.month-navigation {
|
||
display: flex;
|
||
justify-content: center;
|
||
align-items: center;
|
||
gap: var(--spacing-xl);
|
||
margin: 0 auto;
|
||
padding: var(--spacing-lg);
|
||
}
|
||
|
||
.current-month-display {
|
||
font-size: var(--font-size-3xl);
|
||
font-weight: var(--font-weight-bold);
|
||
color: var(--color-text-primary);
|
||
min-width: 280px;
|
||
text-align: center;
|
||
margin: 0;
|
||
}
|
||
|
||
/* Modal - Modern Design */
|
||
.modal-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0, 0, 0, 0.6);
|
||
backdrop-filter: blur(8px);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: var(--z-modal-backdrop);
|
||
padding: var(--spacing-lg);
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.modal-content-modern {
|
||
background: var(--color-surface);
|
||
border-radius: 16px;
|
||
padding: var(--spacing-2xl);
|
||
max-width: 700px;
|
||
width: 100%;
|
||
max-height: 90vh;
|
||
overflow-y: auto;
|
||
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
|
||
position: relative;
|
||
z-index: var(--z-modal);
|
||
animation: slideUp 0.3s ease-out;
|
||
}
|
||
|
||
@keyframes slideUp {
|
||
from {
|
||
opacity: 0;
|
||
transform: translateY(20px);
|
||
}
|
||
to {
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
}
|
||
|
||
.modal-header-modern {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: var(--spacing-2xl);
|
||
padding-bottom: var(--spacing-lg);
|
||
border-bottom: 2px solid var(--color-divider);
|
||
}
|
||
|
||
.modal-title {
|
||
margin: 0;
|
||
color: var(--color-text-primary);
|
||
font-size: var(--font-size-2xl);
|
||
font-weight: var(--font-weight-bold);
|
||
}
|
||
|
||
.btn-close-modern {
|
||
background: var(--color-surface-hover);
|
||
border: none;
|
||
font-size: 24px;
|
||
cursor: pointer;
|
||
color: var(--color-text-secondary);
|
||
padding: 0;
|
||
width: 40px;
|
||
height: 40px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
border-radius: 8px;
|
||
transition: all var(--transition-base);
|
||
}
|
||
|
||
.btn-close-modern:hover {
|
||
background: var(--color-danger);
|
||
color: white;
|
||
transform: rotate(90deg);
|
||
}
|
||
|
||
/* Modern Form Layout */
|
||
.form-row-modern {
|
||
display: grid;
|
||
grid-template-columns: 1fr 1fr;
|
||
gap: var(--spacing-lg);
|
||
}
|
||
|
||
.form-actions-modern {
|
||
display: flex;
|
||
gap: var(--spacing-md);
|
||
justify-content: flex-end;
|
||
padding-top: var(--spacing-xl);
|
||
border-top: 2px solid var(--color-divider);
|
||
margin-top: var(--spacing-lg);
|
||
}
|
||
|
||
/* Ensure textarea inherits form-input-modern styles */
|
||
textarea.form-input-modern {
|
||
resize: vertical;
|
||
min-height: 100px;
|
||
font-family: var(--font-family);
|
||
}
|
||
|
||
/* File Upload - Modern Design */
|
||
.file-input-hidden {
|
||
display: none;
|
||
}
|
||
|
||
.file-upload-area {
|
||
margin-top: var(--spacing-sm);
|
||
}
|
||
|
||
.file-upload-label {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
gap: var(--spacing-md);
|
||
padding: var(--spacing-2xl);
|
||
border: 3px dashed var(--color-primary);
|
||
border-radius: 12px;
|
||
background: rgba(0, 102, 204, 0.03);
|
||
cursor: pointer;
|
||
transition: all var(--transition-base);
|
||
}
|
||
|
||
.file-upload-label:hover {
|
||
background: rgba(0, 102, 204, 0.08);
|
||
border-color: var(--color-primary-dark);
|
||
transform: translateY(-2px);
|
||
}
|
||
|
||
.file-upload-text {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
color: var(--color-text-primary);
|
||
font-size: var(--font-size-base);
|
||
}
|
||
|
||
.file-upload-text strong {
|
||
color: var(--color-primary);
|
||
font-weight: var(--font-weight-bold);
|
||
}
|
||
|
||
.file-upload-text span {
|
||
color: var(--color-text-secondary);
|
||
font-size: var(--font-size-sm);
|
||
}
|
||
|
||
.file-upload-hint {
|
||
color: var(--color-text-muted);
|
||
font-size: var(--font-size-xs);
|
||
}
|
||
|
||
.file-parse-success {
|
||
margin-top: var(--spacing-md);
|
||
padding: var(--spacing-lg);
|
||
background: rgba(0, 200, 83, 0.08);
|
||
border: 2px solid rgba(0, 200, 83, 0.3);
|
||
border-radius: 10px;
|
||
}
|
||
|
||
.parse-success-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
color: var(--color-secondary);
|
||
font-size: var(--font-size-base);
|
||
margin-bottom: var(--spacing-md);
|
||
}
|
||
|
||
.parse-success-details {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-sm);
|
||
}
|
||
|
||
.parse-detail {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: var(--font-size-sm);
|
||
}
|
||
|
||
.parse-label {
|
||
color: var(--color-text-secondary);
|
||
font-weight: var(--font-weight-medium);
|
||
}
|
||
|
||
.parse-value {
|
||
color: var(--color-text-primary);
|
||
font-weight: var(--font-weight-semibold);
|
||
}
|
||
|
||
.error-message-modern {
|
||
margin-top: var(--spacing-lg);
|
||
padding: var(--spacing-md) var(--spacing-lg);
|
||
background: rgba(230, 57, 70, 0.1);
|
||
color: var(--color-danger);
|
||
border-radius: 8px;
|
||
border-left: 4px solid var(--color-danger);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
font-weight: var(--font-weight-medium);
|
||
}
|
||
|
||
/* Calendar Grid - Modern Design */
|
||
.calendar-card {
|
||
padding: var(--spacing-xl);
|
||
overflow-x: auto;
|
||
}
|
||
|
||
.calendar-grid {
|
||
min-width: 100%;
|
||
}
|
||
|
||
.calendar-header-row {
|
||
display: grid;
|
||
grid-template-columns: repeat(7, 1fr);
|
||
gap: 2px;
|
||
margin-bottom: 2px;
|
||
background: var(--color-divider);
|
||
border-radius: 8px 8px 0 0;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.day-header {
|
||
background: var(--color-primary);
|
||
color: white;
|
||
padding: var(--spacing-md) var(--spacing-sm);
|
||
text-align: center;
|
||
font-weight: var(--font-weight-bold);
|
||
font-size: var(--font-size-sm);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.calendar-days-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(7, 1fr);
|
||
gap: 2px;
|
||
background: var(--color-divider);
|
||
border-radius: 0 0 8px 8px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.calendar-day-modern {
|
||
background: var(--color-surface);
|
||
padding: var(--spacing-md);
|
||
min-height: 140px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
transition: all var(--transition-base);
|
||
position: relative;
|
||
}
|
||
|
||
.calendar-day-modern:hover {
|
||
background: var(--color-surface-hover);
|
||
transform: scale(1.02);
|
||
z-index: 1;
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.calendar-day-modern.other-month {
|
||
background: var(--color-surface-secondary);
|
||
opacity: 0.5;
|
||
}
|
||
|
||
.calendar-day-modern.today {
|
||
background: linear-gradient(135deg, rgba(0, 102, 204, 0.08) 0%, rgba(0, 102, 204, 0.02) 100%);
|
||
border: 2px solid var(--color-primary);
|
||
}
|
||
|
||
.calendar-day-modern.has-workouts {
|
||
border-left: 4px solid var(--color-secondary);
|
||
}
|
||
|
||
.day-number-modern {
|
||
font-weight: var(--font-weight-bold);
|
||
color: var(--color-text-primary);
|
||
font-size: var(--font-size-xl);
|
||
margin-bottom: var(--spacing-sm);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
}
|
||
|
||
.today-badge {
|
||
background: var(--color-primary);
|
||
color: white;
|
||
font-size: var(--font-size-xs);
|
||
padding: 4px 8px;
|
||
border-radius: 12px;
|
||
font-weight: var(--font-weight-semibold);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
}
|
||
|
||
.day-workouts-modern {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-sm);
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
.workout-badge-modern {
|
||
background: var(--color-surface-hover);
|
||
border-left: 4px solid var(--color-primary);
|
||
border-radius: 6px;
|
||
padding: var(--spacing-sm);
|
||
cursor: pointer;
|
||
transition: all var(--transition-base);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
|
||
.workout-badge-modern:hover {
|
||
background: rgba(0, 102, 204, 0.08);
|
||
transform: translateX(4px);
|
||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
|
||
}
|
||
|
||
.workout-badge-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
}
|
||
|
||
.workout-badge-icon {
|
||
font-size: var(--font-size-base);
|
||
}
|
||
|
||
.workout-badge-title {
|
||
font-weight: var(--font-weight-semibold);
|
||
font-size: var(--font-size-xs);
|
||
color: var(--color-text-primary);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
flex: 1;
|
||
}
|
||
|
||
.workout-badge-footer {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
gap: var(--spacing-xs);
|
||
font-size: var(--font-size-xs);
|
||
}
|
||
|
||
.workout-badge-duration {
|
||
color: var(--color-text-secondary);
|
||
font-weight: var(--font-weight-medium);
|
||
}
|
||
|
||
/* Upcoming Workouts Table - Modern Design */
|
||
.upcoming-workouts-table {
|
||
margin-top: var(--spacing-md);
|
||
}
|
||
|
||
.workout-row {
|
||
cursor: pointer;
|
||
transition: all var(--transition-base);
|
||
}
|
||
|
||
.workout-row:hover {
|
||
background: rgba(0, 102, 204, 0.05) !important;
|
||
}
|
||
|
||
.workout-date-cell {
|
||
padding: var(--spacing-md) !important;
|
||
}
|
||
|
||
.date-display {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
padding: var(--spacing-sm);
|
||
background: var(--color-primary);
|
||
border-radius: 8px;
|
||
min-width: 50px;
|
||
color: white;
|
||
}
|
||
|
||
.date-day {
|
||
font-size: var(--font-size-2xl);
|
||
font-weight: var(--font-weight-bold);
|
||
line-height: 1;
|
||
}
|
||
|
||
.date-month {
|
||
font-size: var(--font-size-xs);
|
||
font-weight: var(--font-weight-semibold);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.5px;
|
||
margin-top: 2px;
|
||
}
|
||
|
||
.workout-title-cell {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--spacing-sm);
|
||
color: var(--color-text-primary);
|
||
}
|
||
|
||
.workout-icon {
|
||
font-size: var(--font-size-lg);
|
||
}
|
||
|
||
.workout-type-badge {
|
||
background: rgba(0, 102, 204, 0.1);
|
||
color: var(--color-primary);
|
||
padding: 6px 12px;
|
||
border-radius: 16px;
|
||
font-size: var(--font-size-xs);
|
||
font-weight: var(--font-weight-semibold);
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.3px;
|
||
}
|
||
|
||
.duration-display {
|
||
color: var(--color-text-secondary);
|
||
font-weight: var(--font-weight-medium);
|
||
}
|
||
|
||
.btn-action {
|
||
background: transparent;
|
||
border: none;
|
||
color: var(--color-primary);
|
||
cursor: pointer;
|
||
padding: var(--spacing-xs);
|
||
border-radius: 6px;
|
||
transition: all var(--transition-base);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.btn-action:hover {
|
||
background: rgba(0, 102, 204, 0.1);
|
||
}
|
||
|
||
.text-secondary {
|
||
color: var(--color-text-secondary);
|
||
}
|
||
|
||
/* Responsive Design */
|
||
@media (max-width: 1024px) {
|
||
.day-header {
|
||
font-size: var(--font-size-xs);
|
||
padding: var(--spacing-sm);
|
||
}
|
||
|
||
.calendar-day-modern {
|
||
min-height: 120px;
|
||
padding: var(--spacing-sm);
|
||
}
|
||
|
||
.day-number-modern {
|
||
font-size: var(--font-size-lg);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.calendar-content {
|
||
padding: var(--spacing-lg) var(--spacing-md);
|
||
}
|
||
|
||
.section-header {
|
||
flex-direction: column;
|
||
align-items: flex-start;
|
||
gap: var(--spacing-md);
|
||
}
|
||
|
||
.month-navigation {
|
||
flex-wrap: wrap;
|
||
gap: var(--spacing-md);
|
||
}
|
||
|
||
.current-month-display {
|
||
font-size: var(--font-size-2xl);
|
||
min-width: auto;
|
||
width: 100%;
|
||
}
|
||
|
||
.form-row-modern {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.modal-content-modern {
|
||
max-width: 100%;
|
||
padding: var(--spacing-xl);
|
||
}
|
||
|
||
.form-actions-modern {
|
||
flex-direction: column-reverse;
|
||
}
|
||
|
||
.form-actions-modern .btn-modern {
|
||
width: 100%;
|
||
}
|
||
|
||
.day-header {
|
||
font-size: 10px;
|
||
padding: var(--spacing-xs);
|
||
}
|
||
|
||
.calendar-day-modern {
|
||
min-height: 100px;
|
||
padding: var(--spacing-xs);
|
||
}
|
||
|
||
.day-number-modern {
|
||
font-size: var(--font-size-base);
|
||
}
|
||
|
||
.workout-badge-modern {
|
||
padding: 6px;
|
||
}
|
||
|
||
.today-badge {
|
||
display: none;
|
||
}
|
||
|
||
/* Table becomes stacked cards on mobile */
|
||
.table-modern {
|
||
display: block;
|
||
}
|
||
|
||
.table-modern thead {
|
||
display: none;
|
||
}
|
||
|
||
.table-modern tbody,
|
||
.table-modern tr,
|
||
.table-modern td {
|
||
display: block;
|
||
}
|
||
|
||
.table-modern tr {
|
||
margin-bottom: var(--spacing-md);
|
||
border: 1px solid var(--color-border);
|
||
border-radius: 8px;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.table-modern td {
|
||
padding: var(--spacing-sm) var(--spacing-md) !important;
|
||
border-bottom: 1px solid var(--color-border) !important;
|
||
text-align: left !important;
|
||
}
|
||
|
||
.table-modern td:last-child {
|
||
border-bottom: none !important;
|
||
}
|
||
|
||
.date-display {
|
||
width: fit-content;
|
||
}
|
||
|
||
.workout-title-cell {
|
||
font-size: var(--font-size-base);
|
||
}
|
||
}
|
||
|
||
@media (max-width: 480px) {
|
||
.calendar-card {
|
||
padding: var(--spacing-md);
|
||
}
|
||
|
||
.btn-modern-large {
|
||
padding: 12px 20px;
|
||
font-size: var(--font-size-base);
|
||
}
|
||
|
||
.modal-content-modern {
|
||
padding: var(--spacing-lg);
|
||
}
|
||
}
|
||
</style> |