Add Workout Library feature with browse, create, and favorites
This commit is contained in:
@@ -98,6 +98,46 @@
|
||||
<span v-show="!sidebarCollapsed">Training Zones</span>
|
||||
</RouterLink>
|
||||
|
||||
<!-- Training Section Divider -->
|
||||
<div class="nav-divider" v-show="!sidebarCollapsed"></div>
|
||||
<div class="nav-section-label" v-show="!sidebarCollapsed">Training</div>
|
||||
|
||||
<RouterLink
|
||||
to="/workouts"
|
||||
class="sidebar-link"
|
||||
@click="closeMobileMenu"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="3" width="7" height="7" stroke="currentColor" stroke-width="2"/>
|
||||
<rect x="14" y="3" width="7" height="7" stroke="currentColor" stroke-width="2"/>
|
||||
<rect x="3" y="14" width="7" height="7" stroke="currentColor" stroke-width="2"/>
|
||||
<rect x="14" y="14" width="7" height="7" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
<span v-show="!sidebarCollapsed">Workout Library</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
to="/workouts/mine"
|
||||
class="sidebar-link"
|
||||
@click="closeMobileMenu"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path d="M22 19C22 19.5304 21.7893 20.0391 21.4142 20.4142C21.0391 20.7893 20.5304 21 20 21H4C3.46957 21 2.96086 20.7893 2.58579 20.4142C2.21071 20.0391 2 19.5304 2 19V5C2 4.46957 2.21071 3.96086 2.58579 3.58579C2.96086 3.21071 3.46957 3 4 3H9L11 6H20C20.5304 6 21.0391 6.21071 21.4142 6.58579C21.7893 6.96086 22 7.46957 22 8V19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span v-show="!sidebarCollapsed">My Workouts</span>
|
||||
</RouterLink>
|
||||
|
||||
<RouterLink
|
||||
to="/workouts/favorites"
|
||||
class="sidebar-link"
|
||||
@click="closeMobileMenu"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path d="M20.84 4.61C20.3292 4.099 19.7228 3.69364 19.0554 3.41708C18.3879 3.14052 17.6725 2.99817 16.95 2.99817C16.2275 2.99817 15.5121 3.14052 14.8446 3.41708C14.1772 3.69364 13.5708 4.099 13.06 4.61L12 5.67L10.94 4.61C9.9083 3.57831 8.50903 2.99871 7.05 2.99871C5.59096 2.99871 4.19169 3.57831 3.16 4.61C2.1283 5.64169 1.54871 7.04097 1.54871 8.5C1.54871 9.95903 2.1283 11.3583 3.16 12.39L4.22 13.45L12 21.23L19.78 13.45L20.84 12.39C21.351 11.8792 21.7563 11.2728 22.0329 10.6054C22.3095 9.93789 22.4518 9.22248 22.4518 8.5C22.4518 7.77752 22.3095 7.0621 22.0329 6.39464C21.7563 5.72718 21.351 5.12075 20.84 4.61Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span v-show="!sidebarCollapsed">Favorites</span>
|
||||
</RouterLink>
|
||||
|
||||
<!-- Community Section Divider -->
|
||||
<div class="nav-divider" v-show="!sidebarCollapsed"></div>
|
||||
<div class="nav-section-label" v-show="!sidebarCollapsed">Community</div>
|
||||
|
||||
438
src/components/MyWorkouts.vue
Normal file
438
src/components/MyWorkouts.vue
Normal file
@@ -0,0 +1,438 @@
|
||||
<template>
|
||||
<div class="my-workouts-page">
|
||||
<ModernNavbar />
|
||||
<div class="my-workouts-content">
|
||||
<header class="page-header">
|
||||
<div class="header-text">
|
||||
<h1>My Workouts</h1>
|
||||
<p>Create and manage your custom workouts.</p>
|
||||
</div>
|
||||
<router-link to="/workouts/create" class="btn-modern btn-modern-primary">
|
||||
<svg viewBox="0 0 24 24" fill="none" class="btn-icon">
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Create Workout
|
||||
</router-link>
|
||||
</header>
|
||||
|
||||
<div v-if="store.loading" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<span>Loading your workouts...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.error" class="error-state">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span>{{ store.error }}</span>
|
||||
<button @click="store.fetchUserWorkouts()" class="btn-modern btn-modern-secondary">
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.userWorkouts.length === 0" class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="3" width="18" height="18" rx="2" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="12" y1="8" x2="12" y2="16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<h3>No workouts yet</h3>
|
||||
<p>Create your first custom workout to get started.</p>
|
||||
<router-link to="/workouts/create" class="btn-modern btn-modern-primary">
|
||||
Create Your First Workout
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-else class="workouts-list">
|
||||
<div v-for="workout in store.userWorkouts" :key="workout.id" class="workout-item">
|
||||
<div class="workout-info" @click="goToWorkout(workout)">
|
||||
<div class="workout-category" :class="`category-${workout.category}`">
|
||||
{{ formatCategory(workout.category) }}
|
||||
</div>
|
||||
<h3>{{ workout.name }}</h3>
|
||||
<p>{{ truncateDescription(workout.description) }}</p>
|
||||
<div class="workout-stats">
|
||||
<span class="stat">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M12 6V12L16 14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
{{ workout.duration_minutes }} min
|
||||
</span>
|
||||
<span class="stat" v-if="workout.intensity_factor">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path d="M13 2L3 14H12L11 22L21 10H12L13 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
IF {{ workout.intensity_factor.toFixed(2) }}
|
||||
</span>
|
||||
<span class="visibility-badge" :class="{ public: workout.is_public }">
|
||||
{{ workout.is_public ? 'Public' : 'Private' }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="workout-actions">
|
||||
<button @click="editWorkout(workout)" class="btn-action" title="Edit">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path d="M11 4H4C3.46957 4 2.96086 4.21071 2.58579 4.58579C2.21071 4.96086 2 5.46957 2 6V20C2 20.5304 2.21071 21.0391 2.58579 21.4142C2.96086 21.7893 3.46957 22 4 22H18C18.5304 22 19.0391 21.7893 19.4142 21.4142C19.7893 21.0391 20 20.5304 20 20V13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M18.5 2.50001C18.8978 2.10219 19.4374 1.87869 20 1.87869C20.5626 1.87869 21.1022 2.10219 21.5 2.50001C21.8978 2.89784 22.1213 3.4374 22.1213 4.00001C22.1213 4.56262 21.8978 5.10219 21.5 5.50001L12 15L8 16L9 12L18.5 2.50001Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
v-if="!workout.is_public"
|
||||
@click="publishWorkout(workout)"
|
||||
class="btn-action"
|
||||
title="Publish"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M2 12H22M12 2C14.5013 4.73835 15.9228 8.29203 16 12C15.9228 15.708 14.5013 19.2616 12 22C9.49872 19.2616 8.07725 15.708 8 12C8.07725 8.29203 9.49872 4.73835 12 2Z" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button @click="confirmDelete(workout)" class="btn-action btn-danger" title="Delete">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<polyline points="3,6 5,6 21,6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M19 6V20C19 21 18 22 17 22H7C6 22 5 21 5 20V6M8 6V4C8 3 9 2 10 2H14C15 2 16 3 16 4V6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ConfirmDialog
|
||||
v-if="showDeleteConfirm"
|
||||
title="Delete Workout"
|
||||
:message="`Are you sure you want to delete '${workoutToDelete?.name}'? This action cannot be undone.`"
|
||||
confirm-text="Delete"
|
||||
variant="danger"
|
||||
@confirm="deleteWorkout"
|
||||
@cancel="showDeleteConfirm = false"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ModernNavbar from './ModernNavbar.vue'
|
||||
import ConfirmDialog from './ui/ConfirmDialog.vue'
|
||||
import { useWorkoutLibraryStore } from '@/stores/workoutLibrary'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useWorkoutLibraryStore()
|
||||
|
||||
const showDeleteConfirm = ref(false)
|
||||
const workoutToDelete = ref(null)
|
||||
|
||||
const categoryLabels = {
|
||||
endurance: 'Endurance',
|
||||
threshold: 'Threshold',
|
||||
vo2max: 'VO2 Max',
|
||||
sprint: 'Sprint',
|
||||
recovery: 'Recovery'
|
||||
}
|
||||
|
||||
function formatCategory(category) {
|
||||
return categoryLabels[category] || category
|
||||
}
|
||||
|
||||
function truncateDescription(text) {
|
||||
if (!text) return ''
|
||||
return text.length > 100 ? text.substring(0, 100) + '...' : text
|
||||
}
|
||||
|
||||
function goToWorkout(workout) {
|
||||
router.push(`/workouts/${workout.id}`)
|
||||
}
|
||||
|
||||
function editWorkout(workout) {
|
||||
router.push(`/workouts/${workout.id}/edit`)
|
||||
}
|
||||
|
||||
async function publishWorkout(workout) {
|
||||
try {
|
||||
await store.publishWorkout(workout.id)
|
||||
} catch (err) {
|
||||
console.error('Failed to publish workout:', err)
|
||||
}
|
||||
}
|
||||
|
||||
function confirmDelete(workout) {
|
||||
workoutToDelete.value = workout
|
||||
showDeleteConfirm.value = true
|
||||
}
|
||||
|
||||
async function deleteWorkout() {
|
||||
if (!workoutToDelete.value) return
|
||||
|
||||
try {
|
||||
await store.deleteWorkout(workoutToDelete.value.id)
|
||||
} catch (err) {
|
||||
console.error('Failed to delete workout:', err)
|
||||
} finally {
|
||||
showDeleteConfirm.value = false
|
||||
workoutToDelete.value = null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchUserWorkouts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.my-workouts-page {
|
||||
min-height: 100vh;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.my-workouts-content {
|
||||
margin-left: 240px;
|
||||
padding: var(--spacing-xl);
|
||||
transition: margin-left var(--transition-base);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.header-text h1 {
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.header-text p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-3xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-state .spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-state svg,
|
||||
.empty-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.error-state span {
|
||||
color: var(--color-danger);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0 0 var(--spacing-lg);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.workouts-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.workout-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-lg);
|
||||
padding: var(--spacing-lg);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.workout-item:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-sm);
|
||||
}
|
||||
|
||||
.workout-info {
|
||||
flex: 1;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workout-category {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.category-endurance {
|
||||
background: rgba(46, 204, 113, 0.15);
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.category-threshold {
|
||||
background: rgba(241, 196, 15, 0.15);
|
||||
color: #f39c12;
|
||||
}
|
||||
|
||||
.category-vo2max {
|
||||
background: rgba(231, 76, 60, 0.15);
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.category-sprint {
|
||||
background: rgba(155, 89, 182, 0.15);
|
||||
color: #8e44ad;
|
||||
}
|
||||
|
||||
.category-recovery {
|
||||
background: rgba(52, 152, 219, 0.15);
|
||||
color: #2980b9;
|
||||
}
|
||||
|
||||
.workout-info h3 {
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.workout-info p {
|
||||
margin: 0 0 var(--spacing-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.workout-stats {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.workout-stats .stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.workout-stats .stat svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.visibility-badge {
|
||||
padding: 2px var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
background: var(--color-surface-secondary);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.visibility-badge.public {
|
||||
background: rgba(46, 204, 113, 0.15);
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.workout-actions {
|
||||
display: flex;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.btn-action {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.btn-action:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-action.btn-danger:hover {
|
||||
border-color: var(--color-danger);
|
||||
color: var(--color-danger);
|
||||
background: rgba(230, 57, 70, 0.05);
|
||||
}
|
||||
|
||||
.btn-action svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.my-workouts-content {
|
||||
margin-left: 0;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.workout-item {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.workout-actions {
|
||||
justify-content: flex-end;
|
||||
padding-top: var(--spacing-md);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
441
src/components/WorkoutCreate.vue
Normal file
441
src/components/WorkoutCreate.vue
Normal file
@@ -0,0 +1,441 @@
|
||||
<template>
|
||||
<div class="workout-create-page">
|
||||
<ModernNavbar />
|
||||
<div class="workout-create-content">
|
||||
<header class="page-header">
|
||||
<router-link to="/workouts/mine" class="back-link">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path d="M19 12H5M12 19L5 12L12 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Back to My Workouts
|
||||
</router-link>
|
||||
<h1>{{ isEditing ? 'Edit Workout' : 'Create Workout' }}</h1>
|
||||
</header>
|
||||
|
||||
<form @submit.prevent="saveWorkout" class="create-form">
|
||||
<div class="form-layout">
|
||||
<div class="main-column">
|
||||
<div class="card-modern">
|
||||
<h2>Basic Information</h2>
|
||||
<div class="form-modern">
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">Workout Name *</label>
|
||||
<input
|
||||
v-model="form.name"
|
||||
type="text"
|
||||
class="form-input-modern"
|
||||
placeholder="e.g., Sweet Spot Intervals"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">Description</label>
|
||||
<textarea
|
||||
v-model="form.description"
|
||||
class="form-input-modern"
|
||||
rows="3"
|
||||
placeholder="Describe the purpose and goals of this workout..."
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">Category *</label>
|
||||
<select v-model="form.category" class="form-input-modern" required>
|
||||
<option value="">Select category</option>
|
||||
<option value="endurance">Endurance</option>
|
||||
<option value="threshold">Threshold</option>
|
||||
<option value="vo2max">VO2 Max</option>
|
||||
<option value="sprint">Sprint</option>
|
||||
<option value="recovery">Recovery</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-group-modern">
|
||||
<label class="form-label-modern">Visibility</label>
|
||||
<select v-model="form.is_public" class="form-input-modern">
|
||||
<option :value="false">Private</option>
|
||||
<option :value="true">Public</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<IntervalBuilder v-model="form.intervals" />
|
||||
</div>
|
||||
|
||||
<div class="side-column">
|
||||
<div class="card-modern stats-preview">
|
||||
<h3>Workout Preview</h3>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Total Duration</span>
|
||||
<span class="stat-value">{{ calculatedDuration }} min</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Intervals</span>
|
||||
<span class="stat-value">{{ form.intervals.length }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Estimated IF</span>
|
||||
<span class="stat-value">{{ calculatedIF }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Estimated TSS</span>
|
||||
<span class="stat-value">{{ calculatedTSS }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-modern">
|
||||
<h3>Tips</h3>
|
||||
<ul class="tips-list">
|
||||
<li>Start with a warmup interval of 5-10 minutes</li>
|
||||
<li>Include rest intervals between hard efforts</li>
|
||||
<li>End with a cooldown of 5-10 minutes</li>
|
||||
<li>Work intervals at 90-105% FTP for threshold</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span>{{ error }}</span>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<router-link to="/workouts/mine" class="btn-modern btn-modern-secondary">
|
||||
Cancel
|
||||
</router-link>
|
||||
<button
|
||||
type="submit"
|
||||
class="btn-modern btn-modern-primary"
|
||||
:disabled="saving || !isValid"
|
||||
>
|
||||
<span v-if="saving" class="btn-spinner"></span>
|
||||
{{ isEditing ? 'Save Changes' : 'Create Workout' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, reactive, computed, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ModernNavbar from './ModernNavbar.vue'
|
||||
import IntervalBuilder from './workout/IntervalBuilder.vue'
|
||||
import { useWorkoutLibraryStore } from '@/stores/workoutLibrary'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useWorkoutLibraryStore()
|
||||
|
||||
const saving = ref(false)
|
||||
const error = ref('')
|
||||
const isEditing = computed(() => !!route.params.workoutId)
|
||||
|
||||
const form = reactive({
|
||||
name: '',
|
||||
description: '',
|
||||
category: '',
|
||||
is_public: false,
|
||||
intervals: []
|
||||
})
|
||||
|
||||
const isValid = computed(() => {
|
||||
return form.name && form.category && form.intervals.length > 0
|
||||
})
|
||||
|
||||
const calculatedDuration = computed(() => {
|
||||
const totalSeconds = form.intervals.reduce((sum, i) => sum + (i.duration_seconds || 0), 0)
|
||||
return Math.round(totalSeconds / 60)
|
||||
})
|
||||
|
||||
const calculatedIF = computed(() => {
|
||||
if (form.intervals.length === 0) return '—'
|
||||
|
||||
let weightedPower = 0
|
||||
let totalDuration = 0
|
||||
|
||||
form.intervals.forEach(interval => {
|
||||
const duration = interval.duration_seconds || 0
|
||||
const power = interval.power_target_value || 0
|
||||
weightedPower += (power / 100) * duration
|
||||
totalDuration += duration
|
||||
})
|
||||
|
||||
if (totalDuration === 0) return '—'
|
||||
return (weightedPower / totalDuration).toFixed(2)
|
||||
})
|
||||
|
||||
const calculatedTSS = computed(() => {
|
||||
const duration = calculatedDuration.value
|
||||
const ifValue = parseFloat(calculatedIF.value)
|
||||
|
||||
if (isNaN(ifValue) || duration === 0) return '—'
|
||||
return Math.round((duration / 60) * ifValue * ifValue * 100)
|
||||
})
|
||||
|
||||
async function loadWorkout() {
|
||||
if (!isEditing.value) return
|
||||
|
||||
try {
|
||||
const workout = await store.fetchWorkout(route.params.workoutId)
|
||||
const intervals = await store.fetchWorkoutIntervals(route.params.workoutId)
|
||||
|
||||
form.name = workout.name
|
||||
form.description = workout.description || ''
|
||||
form.category = workout.category
|
||||
form.is_public = workout.is_public || false
|
||||
form.intervals = intervals
|
||||
} catch (err) {
|
||||
error.value = 'Failed to load workout'
|
||||
}
|
||||
}
|
||||
|
||||
async function saveWorkout() {
|
||||
if (!isValid.value) return
|
||||
|
||||
saving.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name,
|
||||
description: form.description,
|
||||
category: form.category,
|
||||
is_public: form.is_public,
|
||||
duration_minutes: calculatedDuration.value,
|
||||
intensity_factor: parseFloat(calculatedIF.value) || null,
|
||||
tss: parseInt(calculatedTSS.value) || null,
|
||||
intervals: form.intervals.map((interval, index) => ({
|
||||
order_index: index,
|
||||
interval_type: interval.interval_type,
|
||||
duration_seconds: interval.duration_seconds,
|
||||
power_target_type: interval.power_target_type,
|
||||
power_target_value: interval.power_target_value,
|
||||
cadence_target: interval.cadence_target,
|
||||
notes: interval.notes
|
||||
}))
|
||||
}
|
||||
|
||||
if (isEditing.value) {
|
||||
await store.updateWorkout(route.params.workoutId, payload)
|
||||
} else {
|
||||
await store.createWorkout(payload)
|
||||
}
|
||||
|
||||
router.push('/workouts/mine')
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Failed to save workout'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadWorkout()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workout-create-page {
|
||||
min-height: 100vh;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.workout-create-content {
|
||||
margin-left: 240px;
|
||||
padding: var(--spacing-xl);
|
||||
transition: margin-left var(--transition-base);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size-sm);
|
||||
margin-bottom: var(--spacing-md);
|
||||
transition: color var(--transition-base);
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.back-link svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.page-header h1 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: var(--spacing-xl);
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.main-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.card-modern {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.card-modern h2 {
|
||||
margin: 0 0 var(--spacing-lg);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.card-modern h3 {
|
||||
margin: 0 0 var(--spacing-md);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.side-column {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-lg);
|
||||
position: sticky;
|
||||
top: var(--spacing-xl);
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.stats-preview .stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-sm) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.stats-preview .stat-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.tips-list {
|
||||
margin: 0;
|
||||
padding-left: var(--spacing-lg);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
line-height: 1.8;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
padding: var(--spacing-md) var(--spacing-lg);
|
||||
background: rgba(230, 57, 70, 0.1);
|
||||
border: 1px solid rgba(230, 57, 70, 0.3);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-danger);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.error-message svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing-md);
|
||||
padding-top: var(--spacing-xl);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-spinner {
|
||||
display: inline-block;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid currentColor;
|
||||
border-right-color: transparent;
|
||||
border-radius: 50%;
|
||||
animation: spin 0.6s linear infinite;
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.form-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.side-column {
|
||||
position: static;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.workout-create-content {
|
||||
margin-left: 0;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.form-actions .btn-modern {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
174
src/components/WorkoutFavorites.vue
Normal file
174
src/components/WorkoutFavorites.vue
Normal file
@@ -0,0 +1,174 @@
|
||||
<template>
|
||||
<div class="workout-favorites-page">
|
||||
<ModernNavbar />
|
||||
<div class="workout-favorites-content">
|
||||
<header class="page-header">
|
||||
<div class="header-text">
|
||||
<h1>Favorite Workouts</h1>
|
||||
<p>Quick access to your saved workouts.</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div v-if="store.loading" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<span>Loading favorites...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.error" class="error-state">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span>{{ store.error }}</span>
|
||||
<button @click="store.fetchFavorites()" class="btn-modern btn-modern-secondary">
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.favorites.length === 0" class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path d="M20.84 4.61C20.3292 4.099 19.7228 3.69364 19.0554 3.41708C18.3879 3.14052 17.6725 2.99817 16.95 2.99817C16.2275 2.99817 15.5121 3.14052 14.8446 3.41708C14.1772 3.69364 13.5708 4.099 13.06 4.61L12 5.67L10.94 4.61C9.9083 3.57831 8.50903 2.99871 7.05 2.99871C5.59096 2.99871 4.19169 3.57831 3.16 4.61C2.1283 5.64169 1.54871 7.04097 1.54871 8.5C1.54871 9.95903 2.1283 11.3583 3.16 12.39L4.22 13.45L12 21.23L19.78 13.45L20.84 12.39C21.351 11.8792 21.7563 11.2728 22.0329 10.6054C22.3095 9.93789 22.4518 9.22248 22.4518 8.5C22.4518 7.77752 22.3095 7.0621 22.0329 6.39464C21.7563 5.72718 21.351 5.12075 20.84 4.61Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<h3>No favorites yet</h3>
|
||||
<p>Browse the workout library and save your favorites for quick access.</p>
|
||||
<router-link to="/workouts" class="btn-modern btn-modern-primary">
|
||||
Browse Workouts
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<div v-else class="workouts-grid">
|
||||
<WorkoutCard
|
||||
v-for="workout in store.favorites"
|
||||
:key="workout.id"
|
||||
:workout="workout"
|
||||
:is-favorited="true"
|
||||
@click="goToWorkout(workout)"
|
||||
@toggle-favorite="toggleFavorite"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ModernNavbar from './ModernNavbar.vue'
|
||||
import WorkoutCard from './workout/WorkoutCard.vue'
|
||||
import { useWorkoutLibraryStore } from '@/stores/workoutLibrary'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useWorkoutLibraryStore()
|
||||
|
||||
function goToWorkout(workout) {
|
||||
router.push(`/workouts/${workout.id}`)
|
||||
}
|
||||
|
||||
async function toggleFavorite(workoutId) {
|
||||
try {
|
||||
await store.toggleFavorite(workoutId)
|
||||
} catch (err) {
|
||||
console.error('Failed to remove favorite:', err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchFavorites()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workout-favorites-page {
|
||||
min-height: 100vh;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.workout-favorites-content {
|
||||
margin-left: 240px;
|
||||
padding: var(--spacing-xl);
|
||||
transition: margin-left var(--transition-base);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.header-text h1 {
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.header-text p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-3xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-state .spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-state svg,
|
||||
.empty-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.error-state span {
|
||||
color: var(--color-danger);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0 0 var(--spacing-lg);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.workouts-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.workout-favorites-content {
|
||||
margin-left: 0;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.workouts-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
404
src/components/WorkoutLibrary.vue
Normal file
404
src/components/WorkoutLibrary.vue
Normal file
@@ -0,0 +1,404 @@
|
||||
<template>
|
||||
<div class="workout-library-page">
|
||||
<ModernNavbar />
|
||||
<div class="workout-library-content">
|
||||
<header class="page-header">
|
||||
<div class="header-text">
|
||||
<h1>Workout Library</h1>
|
||||
<p>Browse and discover structured workouts to improve your cycling performance.</p>
|
||||
</div>
|
||||
<router-link to="/workouts/create" class="btn-modern btn-modern-primary">
|
||||
<svg viewBox="0 0 24 24" fill="none" class="btn-icon">
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Create Workout
|
||||
</router-link>
|
||||
</header>
|
||||
|
||||
<div class="content-layout">
|
||||
<aside class="filters-sidebar">
|
||||
<WorkoutFilters
|
||||
:filters="store.filters"
|
||||
@update:filters="handleFilterChange"
|
||||
/>
|
||||
</aside>
|
||||
|
||||
<main class="workouts-main">
|
||||
<div class="results-header">
|
||||
<span class="results-count" v-if="!store.loading">
|
||||
{{ store.pagination.total }} workout{{ store.pagination.total !== 1 ? 's' : '' }} found
|
||||
</span>
|
||||
<div class="view-toggle">
|
||||
<button
|
||||
:class="{ active: viewMode === 'grid' }"
|
||||
@click="viewMode = 'grid'"
|
||||
title="Grid view"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<rect x="3" y="3" width="7" height="7" stroke="currentColor" stroke-width="2"/>
|
||||
<rect x="14" y="3" width="7" height="7" stroke="currentColor" stroke-width="2"/>
|
||||
<rect x="3" y="14" width="7" height="7" stroke="currentColor" stroke-width="2"/>
|
||||
<rect x="14" y="14" width="7" height="7" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
:class="{ active: viewMode === 'list' }"
|
||||
@click="viewMode = 'list'"
|
||||
title="List view"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<line x1="3" y1="6" x2="21" y2="6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="3" y1="12" x2="21" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="3" y1="18" x2="21" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="store.loading" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<span>Loading workouts...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.error" class="error-state">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span>{{ store.error }}</span>
|
||||
<button @click="store.fetchWorkouts()" class="btn-modern btn-modern-secondary">
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else-if="store.workouts.length === 0" class="empty-state">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M21 21L16.65 16.65" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<h3>No workouts found</h3>
|
||||
<p>Try adjusting your filters or search terms.</p>
|
||||
<button @click="store.clearFilters(); store.fetchWorkouts()" class="btn-modern btn-modern-secondary">
|
||||
Clear Filters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-else :class="['workouts-grid', `view-${viewMode}`]">
|
||||
<WorkoutCard
|
||||
v-for="workout in store.workouts"
|
||||
:key="workout.id"
|
||||
:workout="workout"
|
||||
:is-favorited="store.isFavorited(workout.id)"
|
||||
@click="goToWorkout(workout)"
|
||||
@toggle-favorite="toggleFavorite"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div v-if="totalPages > 1" class="pagination">
|
||||
<button
|
||||
:disabled="store.pagination.page === 1"
|
||||
@click="changePage(store.pagination.page - 1)"
|
||||
class="btn-page"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path d="M15 18L9 12L15 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
<span class="page-info">
|
||||
Page {{ store.pagination.page }} of {{ totalPages }}
|
||||
</span>
|
||||
<button
|
||||
:disabled="store.pagination.page >= totalPages"
|
||||
@click="changePage(store.pagination.page + 1)"
|
||||
class="btn-page"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path d="M9 18L15 12L9 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import ModernNavbar from './ModernNavbar.vue'
|
||||
import WorkoutFilters from './workout/WorkoutFilters.vue'
|
||||
import WorkoutCard from './workout/WorkoutCard.vue'
|
||||
import { useWorkoutLibraryStore } from '@/stores/workoutLibrary'
|
||||
|
||||
const router = useRouter()
|
||||
const store = useWorkoutLibraryStore()
|
||||
|
||||
const viewMode = ref('grid')
|
||||
|
||||
const totalPages = computed(() => {
|
||||
return Math.ceil(store.pagination.total / store.pagination.limit)
|
||||
})
|
||||
|
||||
function handleFilterChange(filters) {
|
||||
store.setFilters(filters)
|
||||
store.fetchWorkouts()
|
||||
}
|
||||
|
||||
function changePage(page) {
|
||||
store.setPage(page)
|
||||
store.fetchWorkouts()
|
||||
}
|
||||
|
||||
function goToWorkout(workout) {
|
||||
router.push(`/workouts/${workout.id}`)
|
||||
}
|
||||
|
||||
async function toggleFavorite(workoutId) {
|
||||
try {
|
||||
await store.toggleFavorite(workoutId)
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle favorite:', err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
store.fetchWorkouts()
|
||||
store.fetchFavorites()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workout-library-page {
|
||||
min-height: 100vh;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.workout-library-content {
|
||||
margin-left: 240px;
|
||||
padding: var(--spacing-xl);
|
||||
transition: margin-left var(--transition-base);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.header-text h1 {
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.header-text p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.content-layout {
|
||||
display: grid;
|
||||
grid-template-columns: 280px 1fr;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.filters-sidebar {
|
||||
position: sticky;
|
||||
top: var(--spacing-xl);
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.workouts-main {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.results-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.results-count {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.view-toggle {
|
||||
display: flex;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.view-toggle button {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.view-toggle button:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.view-toggle button.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.view-toggle button svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.workouts-grid {
|
||||
display: grid;
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.workouts-grid.view-grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(320px, 1fr));
|
||||
}
|
||||
|
||||
.workouts-grid.view-list {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state,
|
||||
.empty-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-3xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-state .spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-state svg,
|
||||
.empty-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.error-state span {
|
||||
color: var(--color-danger);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.empty-state h3 {
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
font-size: var(--font-size-lg);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.empty-state p {
|
||||
margin: 0 0 var(--spacing-lg);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.pagination {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
margin-top: var(--spacing-xl);
|
||||
padding-top: var(--spacing-xl);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.btn-page {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.btn-page:hover:not(:disabled) {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.btn-page:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.btn-page svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.page-info {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.content-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.filters-sidebar {
|
||||
position: static;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.workout-library-content {
|
||||
margin-left: 0;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.page-header {
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.workouts-grid.view-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
499
src/components/WorkoutLibraryDetail.vue
Normal file
499
src/components/WorkoutLibraryDetail.vue
Normal file
@@ -0,0 +1,499 @@
|
||||
<template>
|
||||
<div class="workout-detail-page">
|
||||
<ModernNavbar />
|
||||
<div class="workout-detail-content">
|
||||
<div v-if="loading" class="loading-state">
|
||||
<div class="spinner"></div>
|
||||
<span>Loading workout...</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-state">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
||||
<line x1="12" y1="8" x2="12" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="12" y1="16" x2="12.01" y2="16" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span>{{ error }}</span>
|
||||
<router-link to="/workouts" class="btn-modern btn-modern-secondary">
|
||||
Back to Library
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
<template v-else-if="workout">
|
||||
<div class="detail-header">
|
||||
<router-link to="/workouts" class="back-link">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path d="M19 12H5M12 19L5 12L12 5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
Back to Library
|
||||
</router-link>
|
||||
|
||||
<div class="header-row">
|
||||
<div class="header-info">
|
||||
<div class="workout-category" :class="`category-${workout.category}`">
|
||||
{{ formatCategory(workout.category) }}
|
||||
</div>
|
||||
<h1>{{ workout.name }}</h1>
|
||||
<p v-if="workout.description">{{ workout.description }}</p>
|
||||
</div>
|
||||
<div class="header-actions">
|
||||
<FavoriteButton
|
||||
:is-favorited="store.isFavorited(workout.id)"
|
||||
@toggle="toggleFavorite"
|
||||
/>
|
||||
<button @click="useWorkout" class="btn-modern btn-modern-primary">
|
||||
<svg viewBox="0 0 24 24" fill="none" class="btn-icon">
|
||||
<polygon points="5,3 19,12 5,21" fill="currentColor"/>
|
||||
</svg>
|
||||
Use Workout
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="detail-body">
|
||||
<div class="main-column">
|
||||
<div class="card-modern">
|
||||
<h2>Workout Structure</h2>
|
||||
<IntervalDisplay
|
||||
:intervals="intervals"
|
||||
:show-legend="true"
|
||||
:show-details="true"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="side-column">
|
||||
<div class="card-modern stats-card">
|
||||
<h3>Workout Stats</h3>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Duration</span>
|
||||
<span class="stat-value">{{ workout.duration_minutes }} min</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Intensity Factor</span>
|
||||
<span class="stat-value">{{ formatIF(workout.intensity_factor) }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">TSS</span>
|
||||
<span class="stat-value">{{ workout.tss || '—' }}</span>
|
||||
</div>
|
||||
<div class="stat-row">
|
||||
<span class="stat-label">Times Used</span>
|
||||
<span class="stat-value">{{ workout.use_count || 0 }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-modern rating-card">
|
||||
<h3>Rating</h3>
|
||||
<div class="rating-display">
|
||||
<StarRating
|
||||
:rating="workout.average_rating || 0"
|
||||
:readonly="true"
|
||||
size="large"
|
||||
/>
|
||||
<span class="rating-text">
|
||||
{{ (workout.average_rating || 0).toFixed(1) }}
|
||||
<span class="rating-count">({{ workout.rating_count || 0 }} ratings)</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="user-rating" v-if="!workout.user_rating">
|
||||
<p>Rate this workout:</p>
|
||||
<StarRating
|
||||
:rating="userRating"
|
||||
@rate="submitRating"
|
||||
size="large"
|
||||
/>
|
||||
</div>
|
||||
<div class="user-rating rated" v-else>
|
||||
<p>Your rating:</p>
|
||||
<StarRating :rating="workout.user_rating" :readonly="true" size="large" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-modern" v-if="workout.is_system">
|
||||
<div class="system-info">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M12 16V12M12 8H12.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span>This is a system workout designed by RideAware coaches.</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import ModernNavbar from './ModernNavbar.vue'
|
||||
import IntervalDisplay from './workout/IntervalDisplay.vue'
|
||||
import StarRating from './workout/StarRating.vue'
|
||||
import FavoriteButton from './workout/FavoriteButton.vue'
|
||||
import { useWorkoutLibraryStore } from '@/stores/workoutLibrary'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const store = useWorkoutLibraryStore()
|
||||
|
||||
const workout = ref(null)
|
||||
const intervals = ref([])
|
||||
const loading = ref(true)
|
||||
const error = ref('')
|
||||
const userRating = ref(0)
|
||||
|
||||
const categoryLabels = {
|
||||
endurance: 'Endurance',
|
||||
threshold: 'Threshold',
|
||||
vo2max: 'VO2 Max',
|
||||
sprint: 'Sprint',
|
||||
recovery: 'Recovery'
|
||||
}
|
||||
|
||||
function formatCategory(category) {
|
||||
return categoryLabels[category] || category
|
||||
}
|
||||
|
||||
function formatIF(value) {
|
||||
if (!value) return '—'
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
async function loadWorkout() {
|
||||
loading.value = true
|
||||
error.value = ''
|
||||
|
||||
try {
|
||||
const workoutId = route.params.workoutId
|
||||
workout.value = await store.fetchWorkout(workoutId)
|
||||
intervals.value = await store.fetchWorkoutIntervals(workoutId)
|
||||
await store.fetchFavorites()
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Failed to load workout'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFavorite() {
|
||||
try {
|
||||
await store.toggleFavorite(workout.value.id)
|
||||
} catch (err) {
|
||||
console.error('Failed to toggle favorite:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function submitRating(rating) {
|
||||
try {
|
||||
await store.rateWorkout(workout.value.id, rating)
|
||||
workout.value.user_rating = rating
|
||||
} catch (err) {
|
||||
console.error('Failed to submit rating:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function useWorkout() {
|
||||
try {
|
||||
await store.recordUsage(workout.value.id)
|
||||
router.push(`/training?workout=${workout.value.id}`)
|
||||
} catch (err) {
|
||||
console.error('Failed to record usage:', err)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadWorkout()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workout-detail-page {
|
||||
min-height: 100vh;
|
||||
background: var(--color-background);
|
||||
}
|
||||
|
||||
.workout-detail-content {
|
||||
margin-left: 240px;
|
||||
padding: var(--spacing-xl);
|
||||
transition: margin-left var(--transition-base);
|
||||
}
|
||||
|
||||
.loading-state,
|
||||
.error-state {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--spacing-3xl);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loading-state .spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--color-border);
|
||||
border-top-color: var(--color-primary);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.8s linear infinite;
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.error-state svg {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
color: var(--color-text-secondary);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.error-state span {
|
||||
color: var(--color-danger);
|
||||
margin-bottom: var(--spacing-md);
|
||||
}
|
||||
|
||||
.detail-header {
|
||||
margin-bottom: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.back-link {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
color: var(--color-text-secondary);
|
||||
text-decoration: none;
|
||||
font-size: var(--font-size-sm);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
transition: color var(--transition-base);
|
||||
}
|
||||
|
||||
.back-link:hover {
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.back-link svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.header-info {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.workout-category {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.category-endurance {
|
||||
background: rgba(46, 204, 113, 0.15);
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.category-threshold {
|
||||
background: rgba(241, 196, 15, 0.15);
|
||||
color: #f39c12;
|
||||
}
|
||||
|
||||
.category-vo2max {
|
||||
background: rgba(231, 76, 60, 0.15);
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.category-sprint {
|
||||
background: rgba(155, 89, 182, 0.15);
|
||||
color: #8e44ad;
|
||||
}
|
||||
|
||||
.category-recovery {
|
||||
background: rgba(52, 152, 219, 0.15);
|
||||
color: #2980b9;
|
||||
}
|
||||
|
||||
.header-info h1 {
|
||||
margin: 0 0 var(--spacing-sm);
|
||||
font-size: var(--font-size-2xl);
|
||||
font-weight: var(--font-weight-bold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.header-info p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 320px;
|
||||
gap: var(--spacing-xl);
|
||||
}
|
||||
|
||||
.main-column {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-modern {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.card-modern:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.card-modern h2 {
|
||||
margin: 0 0 var(--spacing-lg);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.card-modern h3 {
|
||||
margin: 0 0 var(--spacing-md);
|
||||
font-size: var(--font-size-base);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.stats-card .stat-row {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: var(--spacing-sm) 0;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.stats-card .stat-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.rating-display {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.rating-text {
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.rating-count {
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-normal);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.user-rating {
|
||||
padding-top: var(--spacing-md);
|
||||
border-top: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.user-rating p {
|
||||
margin: 0 0 var(--spacing-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.user-rating.rated {
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.system-info {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-sm);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.system-info svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 1024px) {
|
||||
.detail-body {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.side-column {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
|
||||
gap: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.side-column .card-modern {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.workout-detail-content {
|
||||
margin-left: 0;
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.header-row {
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.side-column {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
61
src/components/workout/FavoriteButton.vue
Normal file
61
src/components/workout/FavoriteButton.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<button
|
||||
type="button"
|
||||
class="favorite-btn"
|
||||
:class="{ favorited: isFavorited }"
|
||||
@click="$emit('toggle')"
|
||||
:title="isFavorited ? 'Remove from favorites' : 'Add to favorites'"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M20.84 4.61C20.3292 4.099 19.7228 3.69364 19.0554 3.41708C18.3879 3.14052 17.6725 2.99817 16.95 2.99817C16.2275 2.99817 15.5121 3.14052 14.8446 3.41708C14.1772 3.69364 13.5708 4.099 13.06 4.61L12 5.67L10.94 4.61C9.9083 3.57831 8.50903 2.99871 7.05 2.99871C5.59096 2.99871 4.19169 3.57831 3.16 4.61C2.1283 5.64169 1.54871 7.04097 1.54871 8.5C1.54871 9.95903 2.1283 11.3583 3.16 12.39L4.22 13.45L12 21.23L19.78 13.45L20.84 12.39C21.351 11.8792 21.7563 11.2728 22.0329 10.6054C22.3095 9.93789 22.4518 9.22248 22.4518 8.5C22.4518 7.77752 22.3095 7.0621 22.0329 6.39464C21.7563 5.72718 21.351 5.12075 20.84 4.61Z"
|
||||
:fill="isFavorited ? 'currentColor' : 'none'"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
defineProps({
|
||||
isFavorited: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['toggle'])
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.favorite-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-secondary);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.favorite-btn:hover {
|
||||
background: rgba(231, 76, 60, 0.1);
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.favorite-btn.favorited {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.favorite-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
</style>
|
||||
446
src/components/workout/IntervalBuilder.vue
Normal file
446
src/components/workout/IntervalBuilder.vue
Normal file
@@ -0,0 +1,446 @@
|
||||
<template>
|
||||
<div class="interval-builder">
|
||||
<div class="builder-header">
|
||||
<h3>Workout Intervals</h3>
|
||||
<button type="button" class="btn-modern btn-modern-secondary btn-sm" @click="addInterval">
|
||||
<svg viewBox="0 0 24 24" fill="none" class="btn-icon">
|
||||
<line x1="12" y1="5" x2="12" y2="19" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="5" y1="12" x2="19" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
Add Interval
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<IntervalDisplay :intervals="intervals" :show-labels="true" />
|
||||
|
||||
<div class="intervals-list" v-if="intervals.length > 0">
|
||||
<div
|
||||
v-for="(interval, index) in intervals"
|
||||
:key="interval.id || index"
|
||||
class="interval-row"
|
||||
draggable="true"
|
||||
@dragstart="dragStart(index)"
|
||||
@dragover.prevent
|
||||
@drop="drop(index)"
|
||||
>
|
||||
<div class="drag-handle">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<line x1="8" y1="6" x2="16" y2="6" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="8" y1="12" x2="16" y2="12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="8" y1="18" x2="16" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<div class="interval-fields">
|
||||
<div class="field-group">
|
||||
<label>Type</label>
|
||||
<select v-model="interval.interval_type" class="form-input-modern">
|
||||
<option value="warmup">Warmup</option>
|
||||
<option value="work">Work</option>
|
||||
<option value="rest">Rest</option>
|
||||
<option value="cooldown">Cooldown</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label>Duration</label>
|
||||
<div class="duration-inputs">
|
||||
<input
|
||||
type="number"
|
||||
:value="Math.floor((interval.duration_seconds || 0) / 60)"
|
||||
@input="updateDuration(index, $event, 'minutes')"
|
||||
class="form-input-modern"
|
||||
min="0"
|
||||
placeholder="min"
|
||||
/>
|
||||
<span>:</span>
|
||||
<input
|
||||
type="number"
|
||||
:value="(interval.duration_seconds || 0) % 60"
|
||||
@input="updateDuration(index, $event, 'seconds')"
|
||||
class="form-input-modern"
|
||||
min="0"
|
||||
max="59"
|
||||
placeholder="sec"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label>Power Target</label>
|
||||
<div class="power-inputs">
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="interval.power_target_value"
|
||||
class="form-input-modern"
|
||||
min="0"
|
||||
max="200"
|
||||
placeholder="Value"
|
||||
/>
|
||||
<select v-model="interval.power_target_type" class="form-input-modern">
|
||||
<option value="ftp_percent">% FTP</option>
|
||||
<option value="watts">Watts</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field-group">
|
||||
<label>Cadence</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="interval.cadence_target"
|
||||
class="form-input-modern"
|
||||
min="0"
|
||||
max="200"
|
||||
placeholder="rpm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field-group field-notes">
|
||||
<label>Notes</label>
|
||||
<input
|
||||
type="text"
|
||||
v-model="interval.notes"
|
||||
class="form-input-modern"
|
||||
placeholder="Optional notes..."
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn-remove" @click="removeInterval(index)" title="Remove interval">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<line x1="18" y1="6" x2="6" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
<line x1="6" y1="6" x2="18" y2="18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-else class="empty-intervals">
|
||||
<p>No intervals added yet. Click "Add Interval" to build your workout.</p>
|
||||
</div>
|
||||
|
||||
<div class="quick-add">
|
||||
<span class="quick-add-label">Quick add:</span>
|
||||
<button type="button" class="btn-quick" @click="addQuickInterval('warmup', 300, 55)">
|
||||
5min Warmup
|
||||
</button>
|
||||
<button type="button" class="btn-quick" @click="addQuickInterval('work', 300, 95)">
|
||||
5min @ 95%
|
||||
</button>
|
||||
<button type="button" class="btn-quick" @click="addQuickInterval('rest', 180, 55)">
|
||||
3min Rest
|
||||
</button>
|
||||
<button type="button" class="btn-quick" @click="addQuickInterval('cooldown', 300, 50)">
|
||||
5min Cooldown
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import IntervalDisplay from './IntervalDisplay.vue'
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:modelValue'])
|
||||
|
||||
const intervals = ref([...props.modelValue])
|
||||
const dragIndex = ref(null)
|
||||
|
||||
function addInterval() {
|
||||
intervals.value.push({
|
||||
id: Date.now(),
|
||||
order_index: intervals.value.length,
|
||||
interval_type: 'work',
|
||||
duration_seconds: 300,
|
||||
power_target_type: 'ftp_percent',
|
||||
power_target_value: 90,
|
||||
cadence_target: null,
|
||||
notes: ''
|
||||
})
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
function addQuickInterval(type, duration, power) {
|
||||
intervals.value.push({
|
||||
id: Date.now(),
|
||||
order_index: intervals.value.length,
|
||||
interval_type: type,
|
||||
duration_seconds: duration,
|
||||
power_target_type: 'ftp_percent',
|
||||
power_target_value: power,
|
||||
cadence_target: null,
|
||||
notes: ''
|
||||
})
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
function removeInterval(index) {
|
||||
intervals.value.splice(index, 1)
|
||||
updateOrderIndices()
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
function updateDuration(index, event, unit) {
|
||||
const value = parseInt(event.target.value) || 0
|
||||
const current = intervals.value[index].duration_seconds || 0
|
||||
const minutes = Math.floor(current / 60)
|
||||
const seconds = current % 60
|
||||
|
||||
if (unit === 'minutes') {
|
||||
intervals.value[index].duration_seconds = value * 60 + seconds
|
||||
} else {
|
||||
intervals.value[index].duration_seconds = minutes * 60 + Math.min(value, 59)
|
||||
}
|
||||
emitUpdate()
|
||||
}
|
||||
|
||||
function dragStart(index) {
|
||||
dragIndex.value = index
|
||||
}
|
||||
|
||||
function drop(index) {
|
||||
if (dragIndex.value === null || dragIndex.value === index) return
|
||||
|
||||
const item = intervals.value.splice(dragIndex.value, 1)[0]
|
||||
intervals.value.splice(index, 0, item)
|
||||
updateOrderIndices()
|
||||
emitUpdate()
|
||||
dragIndex.value = null
|
||||
}
|
||||
|
||||
function updateOrderIndices() {
|
||||
intervals.value.forEach((interval, i) => {
|
||||
interval.order_index = i
|
||||
})
|
||||
}
|
||||
|
||||
function emitUpdate() {
|
||||
emit('update:modelValue', [...intervals.value])
|
||||
}
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
intervals.value = [...newVal]
|
||||
}, { deep: true })
|
||||
|
||||
watch(intervals, () => {
|
||||
emitUpdate()
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.interval-builder {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.builder-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.builder-header h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn-sm {
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.btn-icon {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-right: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.intervals-list {
|
||||
margin-top: var(--spacing-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-md);
|
||||
}
|
||||
|
||||
.interval-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-md);
|
||||
background: var(--color-surface-secondary);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
cursor: grab;
|
||||
}
|
||||
|
||||
.interval-row:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.drag-handle {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
color: var(--color-text-secondary);
|
||||
flex-shrink: 0;
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.drag-handle svg {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
}
|
||||
|
||||
.interval-fields {
|
||||
flex: 1;
|
||||
display: grid;
|
||||
grid-template-columns: 120px 140px 200px 80px 1fr;
|
||||
gap: var(--spacing-md);
|
||||
align-items: end;
|
||||
}
|
||||
|
||||
.field-group {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.field-group label {
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.field-notes {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.duration-inputs,
|
||||
.power-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
}
|
||||
|
||||
.duration-inputs input {
|
||||
width: 50px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.duration-inputs span {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.power-inputs input {
|
||||
width: 60px;
|
||||
}
|
||||
|
||||
.power-inputs select {
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.btn-remove {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-secondary);
|
||||
transition: all var(--transition-base);
|
||||
flex-shrink: 0;
|
||||
margin-top: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.btn-remove:hover {
|
||||
background: rgba(230, 57, 70, 0.1);
|
||||
color: var(--color-danger);
|
||||
}
|
||||
|
||||
.btn-remove svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.empty-intervals {
|
||||
margin-top: var(--spacing-lg);
|
||||
padding: var(--spacing-xl);
|
||||
text-align: center;
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.empty-intervals p {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.quick-add {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
margin-top: var(--spacing-lg);
|
||||
padding-top: var(--spacing-lg);
|
||||
border-top: 1px solid var(--color-border);
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.quick-add-label {
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.btn-quick {
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-sm);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-xs);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.btn-quick:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.interval-fields {
|
||||
grid-template-columns: 1fr 1fr;
|
||||
}
|
||||
|
||||
.field-notes {
|
||||
grid-column: span 2;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.interval-fields {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.field-notes {
|
||||
grid-column: span 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
307
src/components/workout/IntervalDisplay.vue
Normal file
307
src/components/workout/IntervalDisplay.vue
Normal file
@@ -0,0 +1,307 @@
|
||||
<template>
|
||||
<div class="interval-display">
|
||||
<div class="interval-chart" v-if="intervals.length > 0">
|
||||
<div
|
||||
v-for="(interval, index) in intervals"
|
||||
:key="index"
|
||||
class="interval-bar"
|
||||
:class="`interval-${interval.interval_type}`"
|
||||
:style="{
|
||||
width: `${getIntervalWidth(interval)}%`,
|
||||
height: `${getIntervalHeight(interval)}%`
|
||||
}"
|
||||
:title="getIntervalTooltip(interval)"
|
||||
>
|
||||
<span class="interval-label" v-if="showLabels && getIntervalWidth(interval) > 8">
|
||||
{{ formatDuration(interval.duration_seconds) }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="no-intervals">
|
||||
No intervals defined
|
||||
</div>
|
||||
|
||||
<div v-if="showLegend" class="interval-legend">
|
||||
<div class="legend-item">
|
||||
<span class="legend-color interval-warmup"></span>
|
||||
<span>Warmup</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-color interval-work"></span>
|
||||
<span>Work</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-color interval-rest"></span>
|
||||
<span>Rest</span>
|
||||
</div>
|
||||
<div class="legend-item">
|
||||
<span class="legend-color interval-cooldown"></span>
|
||||
<span>Cooldown</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="showDetails && intervals.length > 0" class="interval-details">
|
||||
<div v-for="(interval, index) in intervals" :key="index" class="interval-detail-row">
|
||||
<span class="interval-index">{{ index + 1 }}</span>
|
||||
<span class="interval-type-badge" :class="`interval-${interval.interval_type}`">
|
||||
{{ formatType(interval.interval_type) }}
|
||||
</span>
|
||||
<span class="interval-duration">{{ formatDuration(interval.duration_seconds) }}</span>
|
||||
<span class="interval-power">{{ formatPower(interval) }}</span>
|
||||
<span class="interval-cadence" v-if="interval.cadence_target">
|
||||
{{ interval.cadence_target }} rpm
|
||||
</span>
|
||||
<span class="interval-notes" v-if="interval.notes">{{ interval.notes }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
intervals: {
|
||||
type: Array,
|
||||
default: () => []
|
||||
},
|
||||
showLegend: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showDetails: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showLabels: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
const totalDuration = computed(() => {
|
||||
return props.intervals.reduce((sum, i) => sum + (i.duration_seconds || 0), 0)
|
||||
})
|
||||
|
||||
function getIntervalWidth(interval) {
|
||||
if (totalDuration.value === 0) return 0
|
||||
return (interval.duration_seconds / totalDuration.value) * 100
|
||||
}
|
||||
|
||||
function getIntervalHeight(interval) {
|
||||
const power = interval.power_target_value || 50
|
||||
const maxPower = 150
|
||||
return Math.min((power / maxPower) * 100, 100)
|
||||
}
|
||||
|
||||
function getIntervalTooltip(interval) {
|
||||
const type = formatType(interval.interval_type)
|
||||
const duration = formatDuration(interval.duration_seconds)
|
||||
const power = formatPower(interval)
|
||||
return `${type}: ${duration} @ ${power}`
|
||||
}
|
||||
|
||||
function formatType(type) {
|
||||
const types = {
|
||||
warmup: 'Warmup',
|
||||
work: 'Work',
|
||||
rest: 'Rest',
|
||||
cooldown: 'Cooldown'
|
||||
}
|
||||
return types[type] || type
|
||||
}
|
||||
|
||||
function formatDuration(seconds) {
|
||||
if (!seconds) return '0:00'
|
||||
const mins = Math.floor(seconds / 60)
|
||||
const secs = seconds % 60
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`
|
||||
}
|
||||
|
||||
function formatPower(interval) {
|
||||
if (interval.power_target_type === 'ftp_percent') {
|
||||
return `${interval.power_target_value}% FTP`
|
||||
} else if (interval.power_target_type === 'watts') {
|
||||
return `${interval.power_target_value}W`
|
||||
}
|
||||
return `${interval.power_target_value || 0}%`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.interval-display {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.interval-chart {
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
height: 120px;
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--spacing-sm);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.interval-bar {
|
||||
min-width: 4px;
|
||||
border-radius: var(--radius-sm) var(--radius-sm) 0 0;
|
||||
transition: all var(--transition-base);
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.interval-bar:hover {
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.interval-warmup {
|
||||
background: linear-gradient(to top, #3498db, #5dade2);
|
||||
}
|
||||
|
||||
.interval-work {
|
||||
background: linear-gradient(to top, #e74c3c, #ec7063);
|
||||
}
|
||||
|
||||
.interval-rest {
|
||||
background: linear-gradient(to top, #2ecc71, #58d68d);
|
||||
}
|
||||
|
||||
.interval-cooldown {
|
||||
background: linear-gradient(to top, #9b59b6, #af7ac5);
|
||||
}
|
||||
|
||||
.interval-label {
|
||||
position: absolute;
|
||||
bottom: var(--spacing-xs);
|
||||
font-size: var(--font-size-xs);
|
||||
color: white;
|
||||
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.no-intervals {
|
||||
height: 120px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.interval-legend {
|
||||
display: flex;
|
||||
gap: var(--spacing-lg);
|
||||
margin-top: var(--spacing-md);
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.legend-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.legend-color {
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.legend-color.interval-warmup {
|
||||
background: #3498db;
|
||||
}
|
||||
|
||||
.legend-color.interval-work {
|
||||
background: #e74c3c;
|
||||
}
|
||||
|
||||
.legend-color.interval-rest {
|
||||
background: #2ecc71;
|
||||
}
|
||||
|
||||
.legend-color.interval-cooldown {
|
||||
background: #9b59b6;
|
||||
}
|
||||
|
||||
.interval-details {
|
||||
margin-top: var(--spacing-lg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.interval-detail-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-md);
|
||||
padding: var(--spacing-sm) var(--spacing-md);
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.interval-index {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--color-surface-secondary);
|
||||
border-radius: 50%;
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.interval-type-badge {
|
||||
padding: 2px var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.interval-type-badge.interval-warmup {
|
||||
background: #3498db;
|
||||
}
|
||||
|
||||
.interval-type-badge.interval-work {
|
||||
background: #e74c3c;
|
||||
}
|
||||
|
||||
.interval-type-badge.interval-rest {
|
||||
background: #2ecc71;
|
||||
}
|
||||
|
||||
.interval-type-badge.interval-cooldown {
|
||||
background: #9b59b6;
|
||||
}
|
||||
|
||||
.interval-duration {
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-primary);
|
||||
min-width: 50px;
|
||||
}
|
||||
|
||||
.interval-power {
|
||||
color: var(--color-text-secondary);
|
||||
min-width: 80px;
|
||||
}
|
||||
|
||||
.interval-cadence {
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.interval-notes {
|
||||
flex: 1;
|
||||
color: var(--color-text-secondary);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
105
src/components/workout/StarRating.vue
Normal file
105
src/components/workout/StarRating.vue
Normal file
@@ -0,0 +1,105 @@
|
||||
<template>
|
||||
<div class="star-rating" :class="[`size-${size}`, { readonly }]">
|
||||
<button
|
||||
v-for="star in 5"
|
||||
:key="star"
|
||||
type="button"
|
||||
class="star-btn"
|
||||
:class="{ filled: star <= displayRating, hovered: star <= hoverRating }"
|
||||
@click="!readonly && selectRating(star)"
|
||||
@mouseenter="!readonly && (hoverRating = star)"
|
||||
@mouseleave="hoverRating = 0"
|
||||
:disabled="readonly"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path
|
||||
d="M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26L12 2Z"
|
||||
:fill="star <= displayRating || star <= hoverRating ? 'currentColor' : 'none'"
|
||||
stroke="currentColor"
|
||||
stroke-width="2"
|
||||
stroke-linecap="round"
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
rating: {
|
||||
type: Number,
|
||||
default: 0
|
||||
},
|
||||
readonly: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
size: {
|
||||
type: String,
|
||||
default: 'medium',
|
||||
validator: (v) => ['small', 'medium', 'large'].includes(v)
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:rating', 'rate'])
|
||||
|
||||
const hoverRating = ref(0)
|
||||
|
||||
const displayRating = computed(() => Math.round(props.rating))
|
||||
|
||||
function selectRating(star) {
|
||||
emit('update:rating', star)
|
||||
emit('rate', star)
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.star-rating {
|
||||
display: inline-flex;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.star-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
color: var(--color-border);
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.star-btn:disabled {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.star-btn.filled,
|
||||
.star-btn.hovered {
|
||||
color: #f5a623;
|
||||
}
|
||||
|
||||
.star-btn:not(:disabled):hover {
|
||||
transform: scale(1.1);
|
||||
}
|
||||
|
||||
.readonly .star-btn {
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.size-small .star-btn svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.size-medium .star-btn svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.size-large .star-btn svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
</style>
|
||||
236
src/components/workout/WorkoutCard.vue
Normal file
236
src/components/workout/WorkoutCard.vue
Normal file
@@ -0,0 +1,236 @@
|
||||
<template>
|
||||
<div class="workout-card" @click="$emit('click', workout)">
|
||||
<div class="workout-card-header">
|
||||
<div class="workout-category" :class="`category-${workout.category}`">
|
||||
{{ formatCategory(workout.category) }}
|
||||
</div>
|
||||
<FavoriteButton
|
||||
v-if="showFavorite"
|
||||
:is-favorited="isFavorited"
|
||||
@toggle="$emit('toggle-favorite', workout.id)"
|
||||
@click.stop
|
||||
/>
|
||||
</div>
|
||||
|
||||
<h3 class="workout-name">{{ workout.name }}</h3>
|
||||
<p class="workout-description">{{ truncateDescription(workout.description) }}</p>
|
||||
|
||||
<div class="workout-stats">
|
||||
<div class="stat">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M12 6V12L16 14" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<span>{{ workout.duration_minutes }} min</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path d="M13 2L3 14H12L11 22L21 10H12L13 2Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span>IF {{ formatIF(workout.intensity_factor) }}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path d="M22 12H18L15 21L9 3L6 12H2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span>TSS {{ workout.tss || '—' }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workout-footer">
|
||||
<StarRating :rating="workout.average_rating || 0" :readonly="true" size="small" />
|
||||
<span class="rating-count" v-if="workout.rating_count">({{ workout.rating_count }})</span>
|
||||
<div class="use-count" v-if="workout.use_count">
|
||||
<svg viewBox="0 0 24 24" fill="none">
|
||||
<path d="M17 21V19C17 17.9391 16.5786 16.9217 15.8284 16.1716C15.0783 15.4214 14.0609 15 13 15H5C3.93913 15 2.92172 15.4214 2.17157 16.1716C1.42143 16.9217 1 17.9391 1 19V21" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="9" cy="7" r="4" stroke="currentColor" stroke-width="2"/>
|
||||
</svg>
|
||||
<span>{{ workout.use_count }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="workout.is_system" class="system-badge">System</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import StarRating from './StarRating.vue'
|
||||
import FavoriteButton from './FavoriteButton.vue'
|
||||
|
||||
defineProps({
|
||||
workout: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
isFavorited: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showFavorite: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
})
|
||||
|
||||
defineEmits(['click', 'toggle-favorite'])
|
||||
|
||||
const categoryLabels = {
|
||||
endurance: 'Endurance',
|
||||
threshold: 'Threshold',
|
||||
vo2max: 'VO2 Max',
|
||||
sprint: 'Sprint',
|
||||
recovery: 'Recovery'
|
||||
}
|
||||
|
||||
function formatCategory(category) {
|
||||
return categoryLabels[category] || category
|
||||
}
|
||||
|
||||
function formatIF(value) {
|
||||
if (!value) return '—'
|
||||
return value.toFixed(2)
|
||||
}
|
||||
|
||||
function truncateDescription(text) {
|
||||
if (!text) return ''
|
||||
return text.length > 100 ? text.substring(0, 100) + '...' : text
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workout-card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.workout-card:hover {
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: var(--shadow-md);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
.workout-card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: flex-start;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.workout-category {
|
||||
display: inline-block;
|
||||
padding: var(--spacing-xs) var(--spacing-sm);
|
||||
border-radius: var(--radius-sm);
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.category-endurance {
|
||||
background: rgba(46, 204, 113, 0.15);
|
||||
color: #27ae60;
|
||||
}
|
||||
|
||||
.category-threshold {
|
||||
background: rgba(241, 196, 15, 0.15);
|
||||
color: #f39c12;
|
||||
}
|
||||
|
||||
.category-vo2max {
|
||||
background: rgba(231, 76, 60, 0.15);
|
||||
color: #c0392b;
|
||||
}
|
||||
|
||||
.category-sprint {
|
||||
background: rgba(155, 89, 182, 0.15);
|
||||
color: #8e44ad;
|
||||
}
|
||||
|
||||
.category-recovery {
|
||||
background: rgba(52, 152, 219, 0.15);
|
||||
color: #2980b9;
|
||||
}
|
||||
|
||||
.workout-name {
|
||||
margin: 0 0 var(--spacing-xs);
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.workout-description {
|
||||
margin: 0 0 var(--spacing-md);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
line-height: 1.5;
|
||||
min-height: 42px;
|
||||
}
|
||||
|
||||
.workout-stats {
|
||||
display: flex;
|
||||
gap: var(--spacing-lg);
|
||||
margin-bottom: var(--spacing-md);
|
||||
padding-bottom: var(--spacing-md);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
|
||||
.stat {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
font-size: var(--font-size-sm);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.stat svg {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
}
|
||||
|
||||
.workout-footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.rating-count {
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.use-count {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
margin-left: auto;
|
||||
font-size: var(--font-size-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.use-count svg {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.system-badge {
|
||||
position: absolute;
|
||||
top: var(--spacing-md);
|
||||
right: var(--spacing-md);
|
||||
padding: 2px var(--spacing-xs);
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
font-size: var(--font-size-xs);
|
||||
font-weight: var(--font-weight-medium);
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
.workout-card-header + .system-badge {
|
||||
top: var(--spacing-lg);
|
||||
right: 48px;
|
||||
}
|
||||
</style>
|
||||
280
src/components/workout/WorkoutFilters.vue
Normal file
280
src/components/workout/WorkoutFilters.vue
Normal file
@@ -0,0 +1,280 @@
|
||||
<template>
|
||||
<div class="workout-filters">
|
||||
<div class="filter-header">
|
||||
<h3>Filters</h3>
|
||||
<button v-if="hasActiveFilters" @click="clearFilters" class="btn-clear">
|
||||
Clear all
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">Search</label>
|
||||
<div class="search-input-wrapper">
|
||||
<svg viewBox="0 0 24 24" fill="none" class="search-icon">
|
||||
<circle cx="11" cy="11" r="8" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M21 21L16.65 16.65" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
<input
|
||||
type="text"
|
||||
v-model="localFilters.search"
|
||||
@input="debouncedEmit"
|
||||
placeholder="Search workouts..."
|
||||
class="form-input-modern search-input"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">Category</label>
|
||||
<div class="category-options">
|
||||
<button
|
||||
v-for="cat in categories"
|
||||
:key="cat.value"
|
||||
type="button"
|
||||
class="category-btn"
|
||||
:class="{ active: localFilters.category === cat.value }"
|
||||
@click="toggleCategory(cat.value)"
|
||||
>
|
||||
{{ cat.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">Duration (minutes)</label>
|
||||
<div class="range-inputs">
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="localFilters.duration_min"
|
||||
@input="emitFilters"
|
||||
placeholder="Min"
|
||||
class="form-input-modern range-input"
|
||||
min="0"
|
||||
/>
|
||||
<span class="range-separator">to</span>
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="localFilters.duration_max"
|
||||
@input="emitFilters"
|
||||
placeholder="Max"
|
||||
class="form-input-modern range-input"
|
||||
min="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="filter-section">
|
||||
<label class="filter-label">Intensity Factor</label>
|
||||
<div class="range-inputs">
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="localFilters.intensity_min"
|
||||
@input="emitFilters"
|
||||
placeholder="Min"
|
||||
class="form-input-modern range-input"
|
||||
min="0"
|
||||
max="1.5"
|
||||
step="0.05"
|
||||
/>
|
||||
<span class="range-separator">to</span>
|
||||
<input
|
||||
type="number"
|
||||
v-model.number="localFilters.intensity_max"
|
||||
@input="emitFilters"
|
||||
placeholder="Max"
|
||||
class="form-input-modern range-input"
|
||||
min="0"
|
||||
max="1.5"
|
||||
step="0.05"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, computed, watch } from 'vue'
|
||||
|
||||
const props = defineProps({
|
||||
filters: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['update:filters'])
|
||||
|
||||
const categories = [
|
||||
{ value: 'endurance', label: 'Endurance' },
|
||||
{ value: 'threshold', label: 'Threshold' },
|
||||
{ value: 'vo2max', label: 'VO2 Max' },
|
||||
{ value: 'sprint', label: 'Sprint' },
|
||||
{ value: 'recovery', label: 'Recovery' }
|
||||
]
|
||||
|
||||
const localFilters = ref({
|
||||
search: '',
|
||||
category: '',
|
||||
duration_min: null,
|
||||
duration_max: null,
|
||||
intensity_min: null,
|
||||
intensity_max: null,
|
||||
...props.filters
|
||||
})
|
||||
|
||||
const hasActiveFilters = computed(() => {
|
||||
return localFilters.value.search ||
|
||||
localFilters.value.category ||
|
||||
localFilters.value.duration_min ||
|
||||
localFilters.value.duration_max ||
|
||||
localFilters.value.intensity_min ||
|
||||
localFilters.value.intensity_max
|
||||
})
|
||||
|
||||
let debounceTimer = null
|
||||
function debouncedEmit() {
|
||||
clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => {
|
||||
emitFilters()
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function emitFilters() {
|
||||
emit('update:filters', { ...localFilters.value })
|
||||
}
|
||||
|
||||
function toggleCategory(category) {
|
||||
localFilters.value.category = localFilters.value.category === category ? '' : category
|
||||
emitFilters()
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
localFilters.value = {
|
||||
search: '',
|
||||
category: '',
|
||||
duration_min: null,
|
||||
duration_max: null,
|
||||
intensity_min: null,
|
||||
intensity_max: null
|
||||
}
|
||||
emitFilters()
|
||||
}
|
||||
|
||||
watch(() => props.filters, (newFilters) => {
|
||||
localFilters.value = { ...localFilters.value, ...newFilters }
|
||||
}, { deep: true })
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.workout-filters {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.filter-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.filter-header h3 {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-lg);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.btn-clear {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--color-primary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.btn-clear:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.filter-section {
|
||||
margin-bottom: var(--spacing-lg);
|
||||
}
|
||||
|
||||
.filter-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.filter-label {
|
||||
display: block;
|
||||
margin-bottom: var(--spacing-sm);
|
||||
font-size: var(--font-size-sm);
|
||||
font-weight: var(--font-weight-medium);
|
||||
color: var(--color-text-primary);
|
||||
}
|
||||
|
||||
.search-input-wrapper {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.search-icon {
|
||||
position: absolute;
|
||||
left: var(--spacing-md);
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
.search-input {
|
||||
padding-left: 40px;
|
||||
}
|
||||
|
||||
.category-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.category-btn {
|
||||
padding: var(--spacing-xs) var(--spacing-md);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius-full);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
cursor: pointer;
|
||||
transition: all var(--transition-base);
|
||||
}
|
||||
|
||||
.category-btn:hover {
|
||||
border-color: var(--color-primary);
|
||||
color: var(--color-primary);
|
||||
}
|
||||
|
||||
.category-btn.active {
|
||||
background: var(--color-primary);
|
||||
border-color: var(--color-primary);
|
||||
color: white;
|
||||
}
|
||||
|
||||
.range-inputs {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
|
||||
.range-input {
|
||||
flex: 1;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.range-separator {
|
||||
color: var(--color-text-secondary);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
</style>
|
||||
@@ -17,6 +17,11 @@ import TeamsPage from '@/components/TeamsPage.vue'
|
||||
import TeamDetail from '@/components/TeamDetail.vue'
|
||||
import CoachingPage from '@/components/CoachingPage.vue'
|
||||
import OnboardingWizard from '@/components/OnboardingWizard.vue'
|
||||
import WorkoutLibrary from '@/components/WorkoutLibrary.vue'
|
||||
import WorkoutLibraryDetail from '@/components/WorkoutLibraryDetail.vue'
|
||||
import WorkoutCreate from '@/components/WorkoutCreate.vue'
|
||||
import MyWorkouts from '@/components/MyWorkouts.vue'
|
||||
import WorkoutFavorites from '@/components/WorkoutFavorites.vue'
|
||||
|
||||
const routes = [
|
||||
{
|
||||
@@ -115,6 +120,42 @@ const routes = [
|
||||
component: OnboardingWizard,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/workouts',
|
||||
name: 'WorkoutLibrary',
|
||||
component: WorkoutLibrary,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/workouts/create',
|
||||
name: 'WorkoutCreate',
|
||||
component: WorkoutCreate,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/workouts/mine',
|
||||
name: 'MyWorkouts',
|
||||
component: MyWorkouts,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/workouts/favorites',
|
||||
name: 'WorkoutFavorites',
|
||||
component: WorkoutFavorites,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/workouts/:workoutId',
|
||||
name: 'WorkoutDetail',
|
||||
component: WorkoutLibraryDetail,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/workouts/:workoutId/edit',
|
||||
name: 'WorkoutEdit',
|
||||
component: WorkoutCreate,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{
|
||||
path: '/',
|
||||
redirect: '/dashboard',
|
||||
|
||||
74
src/services/workoutLibraryApi.js
Normal file
74
src/services/workoutLibraryApi.js
Normal file
@@ -0,0 +1,74 @@
|
||||
import api from './api'
|
||||
|
||||
export const workoutLibraryApi = {
|
||||
// Browse & Search
|
||||
async getWorkouts(params = {}) {
|
||||
const { data } = await api.get('/api/protected/workout-library', { params })
|
||||
return data
|
||||
},
|
||||
|
||||
async getWorkout(workoutId) {
|
||||
const { data } = await api.get(`/api/protected/workout-library/${workoutId}`)
|
||||
return data
|
||||
},
|
||||
|
||||
async getWorkoutIntervals(workoutId) {
|
||||
const { data } = await api.get(`/api/protected/workout-library/${workoutId}/intervals`)
|
||||
return data
|
||||
},
|
||||
|
||||
// User's Workouts
|
||||
async getUserWorkouts() {
|
||||
const { data } = await api.get('/api/protected/workouts')
|
||||
return data
|
||||
},
|
||||
|
||||
async createWorkout(workout) {
|
||||
const { data } = await api.post('/api/protected/workouts', workout)
|
||||
return data
|
||||
},
|
||||
|
||||
async updateWorkout(workoutId, workout) {
|
||||
const { data } = await api.put(`/api/protected/workouts/${workoutId}`, workout)
|
||||
return data
|
||||
},
|
||||
|
||||
async deleteWorkout(workoutId) {
|
||||
const { data } = await api.delete(`/api/protected/workouts/${workoutId}`)
|
||||
return data
|
||||
},
|
||||
|
||||
async publishWorkout(workoutId) {
|
||||
const { data } = await api.post(`/api/protected/workouts/${workoutId}/publish`)
|
||||
return data
|
||||
},
|
||||
|
||||
// Favorites
|
||||
async getFavorites() {
|
||||
const { data } = await api.get('/api/protected/workout-favorites')
|
||||
return data
|
||||
},
|
||||
|
||||
async addFavorite(workoutId) {
|
||||
const { data } = await api.post(`/api/protected/workout-favorites/${workoutId}`)
|
||||
return data
|
||||
},
|
||||
|
||||
async removeFavorite(workoutId) {
|
||||
const { data } = await api.delete(`/api/protected/workout-favorites/${workoutId}`)
|
||||
return data
|
||||
},
|
||||
|
||||
// Usage & Ratings
|
||||
async recordUsage(workoutId) {
|
||||
const { data } = await api.post(`/api/protected/workout-library/${workoutId}/use`)
|
||||
return data
|
||||
},
|
||||
|
||||
async rateWorkout(workoutId, rating) {
|
||||
const { data } = await api.post(`/api/protected/workout-library/${workoutId}/rate`, { rating })
|
||||
return data
|
||||
}
|
||||
}
|
||||
|
||||
export default workoutLibraryApi
|
||||
269
src/stores/workoutLibrary.js
Normal file
269
src/stores/workoutLibrary.js
Normal file
@@ -0,0 +1,269 @@
|
||||
import { defineStore } from 'pinia'
|
||||
import { ref, computed } from 'vue'
|
||||
import workoutLibraryApi from '@/services/workoutLibraryApi'
|
||||
|
||||
export const useWorkoutLibraryStore = defineStore('workoutLibrary', () => {
|
||||
const workouts = ref([])
|
||||
const userWorkouts = ref([])
|
||||
const favorites = ref([])
|
||||
const currentWorkout = ref(null)
|
||||
const currentIntervals = ref([])
|
||||
const loading = ref(false)
|
||||
const error = ref(null)
|
||||
|
||||
const filters = ref({
|
||||
category: '',
|
||||
duration_min: null,
|
||||
duration_max: null,
|
||||
intensity_min: null,
|
||||
intensity_max: null,
|
||||
search: ''
|
||||
})
|
||||
|
||||
const pagination = ref({
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0
|
||||
})
|
||||
|
||||
const favoriteIds = computed(() => new Set(favorites.value.map(f => f.id)))
|
||||
|
||||
const isFavorited = (workoutId) => favoriteIds.value.has(workoutId)
|
||||
|
||||
async function fetchWorkouts() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const params = {
|
||||
page: pagination.value.page,
|
||||
limit: pagination.value.limit,
|
||||
...Object.fromEntries(
|
||||
Object.entries(filters.value).filter(([_, v]) => v !== '' && v !== null)
|
||||
)
|
||||
}
|
||||
const data = await workoutLibraryApi.getWorkouts(params)
|
||||
workouts.value = data.workouts || []
|
||||
pagination.value.total = data.total || 0
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Failed to fetch workouts'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWorkout(workoutId) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await workoutLibraryApi.getWorkout(workoutId)
|
||||
currentWorkout.value = data.workout || data
|
||||
return currentWorkout.value
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Failed to fetch workout'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchWorkoutIntervals(workoutId) {
|
||||
try {
|
||||
const data = await workoutLibraryApi.getWorkoutIntervals(workoutId)
|
||||
currentIntervals.value = data.intervals || []
|
||||
return currentIntervals.value
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Failed to fetch intervals'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchUserWorkouts() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await workoutLibraryApi.getUserWorkouts()
|
||||
userWorkouts.value = data.workouts || []
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Failed to fetch your workouts'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function createWorkout(workout) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await workoutLibraryApi.createWorkout(workout)
|
||||
userWorkouts.value.unshift(data.workout || data)
|
||||
return data.workout || data
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Failed to create workout'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function updateWorkout(workoutId, workout) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await workoutLibraryApi.updateWorkout(workoutId, workout)
|
||||
const index = userWorkouts.value.findIndex(w => w.id === workoutId)
|
||||
if (index !== -1) {
|
||||
userWorkouts.value[index] = data.workout || data
|
||||
}
|
||||
return data.workout || data
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Failed to update workout'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function deleteWorkout(workoutId) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
await workoutLibraryApi.deleteWorkout(workoutId)
|
||||
userWorkouts.value = userWorkouts.value.filter(w => w.id !== workoutId)
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Failed to delete workout'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function publishWorkout(workoutId) {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await workoutLibraryApi.publishWorkout(workoutId)
|
||||
const index = userWorkouts.value.findIndex(w => w.id === workoutId)
|
||||
if (index !== -1) {
|
||||
userWorkouts.value[index].is_public = true
|
||||
}
|
||||
return data
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Failed to publish workout'
|
||||
throw err
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchFavorites() {
|
||||
loading.value = true
|
||||
error.value = null
|
||||
|
||||
try {
|
||||
const data = await workoutLibraryApi.getFavorites()
|
||||
favorites.value = data.favorites || data.workouts || []
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Failed to fetch favorites'
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleFavorite(workoutId) {
|
||||
try {
|
||||
if (isFavorited(workoutId)) {
|
||||
await workoutLibraryApi.removeFavorite(workoutId)
|
||||
favorites.value = favorites.value.filter(f => f.id !== workoutId)
|
||||
} else {
|
||||
await workoutLibraryApi.addFavorite(workoutId)
|
||||
const workout = workouts.value.find(w => w.id === workoutId) || currentWorkout.value
|
||||
if (workout) {
|
||||
favorites.value.push(workout)
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Failed to update favorite'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
async function recordUsage(workoutId) {
|
||||
try {
|
||||
await workoutLibraryApi.recordUsage(workoutId)
|
||||
} catch (err) {
|
||||
console.error('Failed to record usage:', err)
|
||||
}
|
||||
}
|
||||
|
||||
async function rateWorkout(workoutId, rating) {
|
||||
try {
|
||||
const data = await workoutLibraryApi.rateWorkout(workoutId, rating)
|
||||
if (currentWorkout.value && currentWorkout.value.id === workoutId) {
|
||||
currentWorkout.value.average_rating = data.average_rating
|
||||
currentWorkout.value.rating_count = data.rating_count
|
||||
currentWorkout.value.user_rating = rating
|
||||
}
|
||||
return data
|
||||
} catch (err) {
|
||||
error.value = err.response?.data?.error || 'Failed to rate workout'
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
function setFilters(newFilters) {
|
||||
filters.value = { ...filters.value, ...newFilters }
|
||||
pagination.value.page = 1
|
||||
}
|
||||
|
||||
function clearFilters() {
|
||||
filters.value = {
|
||||
category: '',
|
||||
duration_min: null,
|
||||
duration_max: null,
|
||||
intensity_min: null,
|
||||
intensity_max: null,
|
||||
search: ''
|
||||
}
|
||||
pagination.value.page = 1
|
||||
}
|
||||
|
||||
function setPage(page) {
|
||||
pagination.value.page = page
|
||||
}
|
||||
|
||||
return {
|
||||
workouts,
|
||||
userWorkouts,
|
||||
favorites,
|
||||
currentWorkout,
|
||||
currentIntervals,
|
||||
loading,
|
||||
error,
|
||||
filters,
|
||||
pagination,
|
||||
favoriteIds,
|
||||
isFavorited,
|
||||
fetchWorkouts,
|
||||
fetchWorkout,
|
||||
fetchWorkoutIntervals,
|
||||
fetchUserWorkouts,
|
||||
createWorkout,
|
||||
updateWorkout,
|
||||
deleteWorkout,
|
||||
publishWorkout,
|
||||
fetchFavorites,
|
||||
toggleFavorite,
|
||||
recordUsage,
|
||||
rateWorkout,
|
||||
setFilters,
|
||||
clearFilters,
|
||||
setPage
|
||||
}
|
||||
})
|
||||
Reference in New Issue
Block a user